Detect proxies and VPNs in PHP and Laravel with a free API (no key)

Detect proxy, VPN and Tor visitors in PHP and Laravel with one free API call, a 5-minute cache file and tested middleware, and why header checks fail.

To detect proxies and VPNs in PHP, look up the visitor's IP address with an IP intelligence API. Proxy headers miss them. The class below asks FFraud's free API with no key. It keeps each answer in a file for 5 minutes and decides by the risk band. It runs in plain PHP and as Laravel middleware. In our test a cached answer took 0.3 ms, against a median of 441 ms for a fresh call.

#Why don't proxy headers work?

Many PHP snippets look for HTTP_VIA, HTTP_X_FORWARDED_FOR or HTTP_CLIENT_IP and treat any of them as a proxy. That fails both ways:

  • VPNs send no such header. A VPN works below HTTP, and an anonymous proxy leaves these headers out on purpose.
  • Your own proxies do send one. A CDN or load balancer adds X-Forwarded-For to every request, so the check would flag every visitor (see our X-Forwarded-For guide).
  • The visitor writes them. Anything in HTTP_CLIENT_IP or X-Forwarded-For can be typed by the client. Laravel's docs call IP addresses "untrusted, user-controlled input" for that reason.

The address that reaches your server is harder to fake, and an IP API knows whether it belongs to a VPN, Tor, a relay or a data center.

#How do you check the address in PHP?

app/Support/IpCheck.php
<?php

namespace App\Support;

// Detect VPN, proxy, Tor and data center visitors in PHP with FFraud's free IP API. No key, no packages.
final class IpCheck
{
    private const API = 'https://api.ffraud.com/public/ip/';

    public function __construct(private string $cacheDir, private int $ttl = 300)
    {
    }

    /** The API's answer for one address, kept as a file for 5 minutes; null when the API cannot answer in time. */
    public function lookup(string $ip, int $timeoutMs = 2000): ?array
    {
        if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
            return null;
        }
        $file = $this->cacheDir . '/ffraud-ip-' . md5($ip) . '.json';
        if (is_file($file) && filemtime($file) > time() - $this->ttl) {
            return json_decode(file_get_contents($file), true);
        }
        $ch = curl_init(self::API . rawurlencode($ip));
        curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT_MS => $timeoutMs]);
        $body = curl_exec($ch);
        $ok = $body !== false && curl_getinfo($ch, CURLINFO_RESPONSE_CODE) === 200;
        curl_close($ch);
        if (!$ok) {
            return null;
        }
        file_put_contents($file, $body, LOCK_EX);
        return json_decode($body, true);
    }

    /** ['allow' | 'challenge' | 'block', reason], by FFraud's risk bands; the flags give the reason. */
    public static function verdict(?array $a): array
    {
        if ($a === null) {
            return ['allow', 'IP check unavailable'];
        }
        $why = match (true) {
            !empty($a['tor']) => 'Tor exit',
            !empty($a['vpn']) => 'VPN' . (!empty($a['vpn_provider']) ? " ({$a['vpn_provider']})" : ''),
            !empty($a['proxy']) => 'proxy',
            !empty($a['relay']) => 'privacy relay',
            !empty($a['hosting']) => 'data center' . (!empty($a['cloud_provider']) ? " ({$a['cloud_provider']})" : ''),
            default => 'no flag',
        };
        $risk = $a['risk'] ?? 'none';
        if ($risk === 'critical' || ($risk === 'high' && !empty($a['recent_abuse']))) {
            return ['block', $why];
        }
        if ($risk === 'high' || $risk === 'medium') {
            return ['challenge', $why];
        }
        return ['allow', $why];
    }
}
  • A timeout of 2 seconds. Without CURLOPT_TIMEOUT_MS, a cURL transfer never times out, and the connect phase may wait 300 seconds (PHP manual). A hung API would hold your PHP worker that long.
  • A cache file per address. PHP starts every request from scratch, so the answer lives in a file for 5 minutes, named after the address's MD5. The API marks its answers cacheable for as long (Cache-Control: max-age=300, October 26, 2026).
  • It fails open. Without an answer, or for anything that is not an IP address, lookup returns null and verdict allows the request.
  • The band decides, the flags explain. Block a critical score or a high one with recent abuse, challenge medium and high, allow the rest (FFraud docs). The flags give the reason.

In plain PHP, include a few lines at the top of each page that creates accounts or takes orders:

check-request.php
<?php
// Plain PHP: include this at the top of a page that creates accounts or takes orders.
require_once __DIR__ . '/app/Support/IpCheck.php';

use App\Support\IpCheck;

// REMOTE_ADDR is the visitor once your own proxy hands over the client address (nginx: the realip module).
[$action, $why] = IpCheck::verdict((new IpCheck(__DIR__ . '/cache'))->lookup($_SERVER['REMOTE_ADDR'] ?? ''));
if ($action === 'block') {
    http_response_code(403);
    exit('Requests from this network are not accepted.');
}
// 'challenge': ask for a CAPTCHA or a confirmed email before the account goes live.

Behind nginx, let its realip module replace REMOTE_ADDR with the visitor's address, and only for your own proxies. Our X-Forwarded-For guide has the setup for Cloudflare.

#How do you add it to Laravel?

app/Http/Middleware/CheckIp.php
<?php

namespace App\Http\Middleware;

use App\Support\IpCheck;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

// Blocks the addresses FFraud scores critical; a "challenge" verdict is left to the controller.
class CheckIp
{
    public function handle(Request $request, Closure $next): Response
    {
        [$action, $why] = IpCheck::verdict(app(IpCheck::class)->lookup((string) $request->ip()));
        if ($action === 'block') {
            abort(403, 'Requests from this network are not accepted.');
        }
        $request->attributes->set('ip_verdict', [$action, $why]);
        return $next($request);
    }
}
the proxies, the alias, the binding and a route
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->trustProxies(at: ['10.0.0.0/8']); // your load balancer or proxy
    $middleware->alias(['ip.check' => \App\Http\Middleware\CheckIp::class]);
})

// app/Providers/AppServiceProvider.php, inside register()
$this->app->singleton(\App\Support\IpCheck::class, fn () => new \App\Support\IpCheck(storage_path('framework/cache')));

// routes/web.php
Route::post('/register', [RegisteredUserController::class, 'store'])->middleware('ip.check');

$request->ip() returns the client's address, and trustProxies names the proxies whose X-Forwarded-For Laravel may believe (Laravel docs). A middleware alias in bootstrap/app.php lets a route ask for it by name. The singleton gives every request the same cache folder.

These wiring lines follow Laravel 13's documentation. We ran the class, the page and the middleware outside Laravel.

#What does each kind of address get?

We drew 20 addresses each from four public lists on October 26, 2026, and read the free API's bands, which decide the verdict:

ListRisk bands from the APIVerdict
Tor exitscritical 20block, 20 of 20
PIA VPN serversmedium 16, high 4challenge 16, block 4
iCloud Private Relaylow 15, none 5allow, 20 of 20
AWS EC2none 17, low 2, medium 1allow 19, challenge 1

The Private Relay addresses came back as data center addresses too. A rule on hosting alone would refuse Safari users with iCloud+. Apple checks each Private Relay connection for an Apple device and an iCloud+ subscription. It also warns that IP-based fraud checks may need adjusting (Apple developer docs).

#What does the check cost a PHP request?

We timed the lookup in fresh PHP processes, the way each PHP request starts. That was 12 times with an empty cache and 12 times with the answer in its file:

What one PHP request pays for the IP checkMilliseconds per lookup in a fresh PHP 8.3.25 process, from our test machine in Germany, October 26, 2026
  1. Empty cache, slowest of 12541 ms
  2. Empty cache, median of 12441 ms
  3. Empty cache, fastest of 12392 ms
  4. Answer in the cache file, median of 120.3 ms

Source: FFraud test on PHP 8.3.25, October 26, 2026

A fresh call pays for a new TLS connection and the API's answer, since each of our test processes started without a connection. The cache file turns repeat visits into a file read. Your own times depend on your distance to the API.

#How do you test it?

We ran the class, the plain page and the middleware on PHP 8.3 on October 26, 2026. We made one API call a second at most. Laravel was not installed: the middleware ran against stand-ins for Request, Symfony's Response, app() and abort():

test run, October 26, 2026, 01:35 UTC
PHP 8.3.25, 2026-10-26T01:35:03Z
45.66.35.28  Tor exit              -> [block, 'Tor exit'] in 1078 ms
104.28.80.101  iCloud Private Relay -> [allow, 'privacy relay'] in 828 ms
62.169.130.76  PIA VPN server      -> [challenge, 'VPN (Private Internet Access)'] in 452 ms
15.193.8.246  AWS EC2              -> [allow, 'data center (Amazon Web Services)'] in 403 ms
8.8.8.8  Google DNS                -> [allow, 'data center (Google Cloud)'] in 433 ms
not-an-ip  garbage                 -> [allow, 'IP check unavailable'] in 0 ms
8.8.8.8 again: answered from the cache file in 0.42 ms
API down (1 ms timeout), 192.0.2.10 -> [allow, 'IP check unavailable']
middleware, Tor exit: abort(403, 'Requests from this network are not accepted.')
middleware, PIA VPN server: next() ran, ip_verdict ["challenge","VPN (Private Internet Access)"]
middleware, iCloud Private Relay: next() ran, ip_verdict ["allow","privacy relay"]
check-request.php, Tor exit: Requests from this network are not accepted.
check-request.php, PIA VPN server: page goes on: challenge, VPN (Private Internet Access)
timing run, October 26, 2026, 01:35 UTC
PHP 8.3.25, 2026-10-26T01:35:29Z
empty cache, one API call (ms): 442.6 541.2 400.4 431.0 508.8 406.3 439.4 456.5 391.7 497.5 397.0 444.7
answer in the cache file (ms): 0.2 0.2 0.3 0.2 0.3 0.2 0.2 0.3 0.3 0.3 0.3 0.3
median empty cache: 441 ms (fastest 392, slowest 541)
median from the cache file: 0.30 ms (fastest 0.20, slowest 0.30)

Run the middleware once inside your own app before you ship it.

#Questions people ask

How do I detect a proxy in PHP?

Look up REMOTE_ADDR, or Laravel's $request->ip(), with an IP API and act on the risk band. The class above does it with FFraud's free API and a 5-minute cache file.

Can PHP detect a VPN from HTTP headers?

Not reliably. A VPN works below HTTP and adds no header, while your own CDN adds X-Forwarded-For to every request.

How do I get the real client IP in Laravel behind a load balancer?

Name your proxies with $middleware->trustProxies(at: [...]) in bootstrap/app.php. $request->ip() then returns the client's address (Laravel docs).

Should I block every VPN user?

The bands above block a VPN only when it scores critical, or high with recent abuse. In our sample that was four of 20 PIA servers; the other 16 got a challenge, such as a CAPTCHA, and can go on.

How long should I cache an IP lookup?

Five minutes. The API marks its answers cacheable for that long, and the class keeps each one in a file just as long.

Is there a free IP API without a key for PHP?

FFraud's /public/ip/{ip} needs no key. It answers with tor, vpn, proxy, relay and hosting flags, a score and a risk band.

To look up one address by hand, use the free proxy and VPN check or the IP lookup. Every field of the answer is listed in the IP intelligence docs. What is my ISP shows how sites classify your own connection.

Sources

  1. IP intelligence, FFraud docs
  2. HTTP Requests: request IP address and trusted proxies, Laravel documentation 13.x
  3. Middleware: middleware aliases, Laravel documentation 13.x
  4. cURL predefined constants: CURLOPT_TIMEOUT_MS and CURLOPT_CONNECTTIMEOUT, PHP manual
  5. Prepare your network or web server for iCloud Private Relay, Apple Developer
  6. Tor bulk exit list, The Tor Project, October 26, 2026
  7. AWS IP address ranges, Amazon VPC User Guide
  8. Server list, version 6, Private Internet Access, October 26, 2026