Every Laravel tutorial about blocking an IP starts the same way: an array of IP addresses, an in_array check, abort(403). That works when you already know which IP is bad. Signup abuse is the case where you never do. The address that just registered four accounts in six minutes is one you have never seen, and by tomorrow it will be a different one.
So the check has to run against a risk score, not a list. The API call is the easy part. The four decisions around it are where this actually gets interesting.
TL;DR
- A static blocklist cannot stop signup abuse, because in signup abuse the IP is new every time. Score it instead.
- Fix
$request->ip()first. Behind a load balancer it returns your own infrastructure unless trusted proxies are configured, and the wildcard*setting is worse than doing nothing. - Do not block on
is_vpn. Blocking every VPN exit node blocks your corporate customers. Block onthreat_scoreplus a corroborating signal such asis_known_attacker. - Use four bands, not one threshold: allow, observe, challenge, block. The 45 to 79 band should add friction, not reject.
- Cache the verdict, set a 1.5 second timeout, and decide fail-open or fail-closed on purpose rather than by accident.
The code below is a service, a middleware, and a Pest test. It runs on Laravel 11, 12 and 13, with a note for 10 and earlier. Total build time is about twenty minutes, most of it spent choosing thresholds rather than writing code.
Why a blocklist is the wrong shape for this problem
There are good Laravel packages for banning IPs. Banhammer has 87,245 installs and does exactly what it says: bans Eloquent models, IP addresses, and whole countries, with expiry handling and middleware. orkhanahmadov/laravel-ip-middleware gives you ip_whitelist and ip_blacklist middleware in two lines. Both are fine.
They are also ban stores. They record a decision something else made. If you already know 203.0.113.40 belongs to an abuser, Banhammer will keep it out. Nothing in it tells you that the IP registering right now is a residential proxy exit that was last seen running credential stuffing three weeks ago.
That is the gap for signup abuse specifically. A blocklist is reactive by construction: an IP gets on it after it has already done something. At a registration form you get one shot, before the user record exists, and the attacker rotates addresses faster than you can add them.
Scoring flips the order. You ask a question about an address you have never seen and get an answer in about 40 milliseconds. What you do with that answer is your policy, and that policy is the rest of this article.
Get the client IP right before anything else
Every later decision is garbage if this one is wrong, which is why it goes first rather than in a pitfalls section at the bottom.
$request->ip() returns the address that opened the TCP connection. Behind nginx, a load balancer, Cloudflare, or anything else that terminates the connection for you, that is the proxy, not the user. Score it and you will get the same verdict for every signup on your site, which is either "allow everything" or "block everything" depending on your host.
Laravel ships Illuminate\Http\Middleware\TrustProxies in the default global stack. It reads X-Forwarded-For only for requests that arrived from a proxy you named. In Laravel 11 and up you configure it in bootstrap/app.php:
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware): void {
// Name the actual proxy ranges. REMOTE_ADDR is the one request
// property a client cannot forge, so it is what the trust check runs against.
$middleware->trustProxies(
at: ['10.0.0.0/8', '172.16.0.0/12'],
headers: Request::HEADER_X_FORWARDED_FOR
| Request::HEADER_X_FORWARDED_HOST
| Request::HEADER_X_FORWARDED_PROTO
| Request::HEADER_X_FORWARDED_PORT,
);
})
->create();
On Laravel 10 and earlier the same thing lives in app/Http/Middleware/TrustProxies.php as protected $proxies.
Pitfall:
trustProxies(at: '*')looks convenient and is the single worst thing you can do to an IP-based control. With the wildcard set, anyone who can reach your app server directly can sendX-Forwarded-For: 1.1.1.1and be whoever they want. Your rate limits, your admin IP allowlist, your audit logs and this middleware all start reading attacker-supplied input. Name the ranges. If you are on a platform whose proxy IPs rotate, use a package that fetches and caches the current list rather than reaching for the asterisk.
Confirm it works before you build on it. Log $request->ip() on a staging route, hit it from your phone on mobile data, and check that what you see is your phone's address and not 10.x.x.x.
What the risk response actually contains
The lookup endpoint is https://api.ipgeolocation.io/v3/security. Here is the full response for 145.223.7.7, which is the example the IP Security API reference publishes:
{
"ip": "145.223.7.7",
"security": {
"threat_score": 90,
"is_tor": false,
"is_proxy": true,
"proxy_provider_names": ["NetNut", "ProxyScrape", "Oxy Labs", "DataImpulse"],
"proxy_confidence_score": 99,
"proxy_last_seen": "2026-09-01",
"is_residential_proxy": true,
"is_vpn": true,
"vpn_provider_names": ["SurfShark VPN", "Ishaan VPN"],
"vpn_confidence_score": 99,
"vpn_last_seen": "2026-07-31",
"is_relay": false,
"relay_provider_name": "",
"is_anonymous": true,
"is_known_attacker": true,
"is_bot": false,
"bot_confidence_score": 0,
"bot_operator_name": "",
"bot_type": "",
"is_known_good_bot": false,
"bot_last_seen": "",
"is_spam": true,
"is_cloud_provider": true,
"cloud_provider_name": "Brander Group Inc.",
"is_corporate_gateway": false,
"corporate_gateway_type": "",
"corporate_gateway_provider_name": ""
}
}
Twenty-seven fields. Four of them decide a signup and the rest are context for your logs.
threat_score is 0 to 100 and is the routing field. It aggregates everything else.
is_known_attacker is the strongest single flag. It is set directly by the attack bot types, so bot_type values of brute_force, credential_stuffing, exploit and worm all arrive with is_known_attacker: true. If you only ever act on one boolean, act on this one.
is_residential_proxy matters more than is_proxy for signups. A datacenter proxy is easy to spot and most abuse tooling has moved past it. Residential proxy networks rent real consumer connections, which is why the address above shows is_residential_proxy: true on an IP that also carries four proxy provider names.
is_corporate_gateway is the one that will save you support tickets. It marks the egress address of an enterprise secure web gateway, Zscaler and Netskope and that category, with corporate_gateway_type and the provider name. One of those IPs can front an entire company's workforce. Note that is_anonymous stays false for them: the range is published and belongs to an identifiable business, so the gateway flag on its own does not raise the threat score.
The rest, including vpn_provider_names, proxy_last_seen and bot_operator_name, are what you write to your audit log so that when someone asks why an account was rejected, you can answer with something better than "the API said no."
Here is the part I want to be blunt about. Do not block on is_vpn. It is the first flag everyone reaches for and it is the wrong one. Somewhere between a lot and most VPN traffic is a privacy-conscious person or an employee whose laptop routes everything through a tunnel by corporate policy. Reject on that flag and you have not built fraud prevention, you have built a support queue. is_vpn belongs in your logs and possibly in a friction decision. It does not belong in a reject branch on its own.
Scoring bands, not a single threshold
One threshold forces a binary you do not actually want. Four bands give you somewhere to put the ambiguous middle, which is where most real traffic lands.
| Band | threat_score |
What the signup gets | Why |
|---|---|---|---|
| Allow | 0 to 19 | Normal registration | Standard controls are enough |
| Observe | 20 to 44 | Registers, flagged in logs | Needs a second signal before it means anything |
| Challenge | 45 to 79 | Email verification or OTP before activation | Real risk, but rejecting costs you real customers |
| Block | 80 to 100 | Rejected, or queued for manual review | Blocking territory |
Two overrides sit on top of the score, and they are the difference between a gate that works and one that generates complaints:
-
is_known_attackerpromotes to Block regardless of score. The flag is specific enough to trust on its own. -
is_corporate_gatewaycaps the band at Observe. Whatever the score says, you are looking at a shared egress for a company. Log it, do not challenge it, and never use that IP for per-user deduplication.
The Challenge band is the one people skip, and skipping it is why blocklists get a bad reputation. A score of 60 means the address has some history, not that this person is a fraudster. An emailed code costs a legitimate user fifteen seconds and costs an attacker a mailbox they have to provision per account. That asymmetry is most of the value in the whole system.
Start conservative. Ship with Block at 80 and Challenge at 45, watch the band distribution against your confirmed-fraud data for two weeks, then tighten. Numbers tuned on someone else's traffic are a guess.
The middleware
Three files: a value object, a service, and the middleware itself.
The verdict
<?php
namespace App\Support\IpRisk;
enum RiskBand: string
{
case Allow = 'allow';
case Observe = 'observe';
case Challenge = 'challenge';
case Block = 'block';
}
<?php
namespace App\Support\IpRisk;
final readonly class IpRiskVerdict
{
/** @param list<string> $reasons */
public function __construct(
public RiskBand $band,
public int $score,
public array $reasons = [],
// True when we could not reach the provider and fell back to a default.
// Log this separately; a spike means the gate is open, not that traffic got safer.
public bool $degraded = false,
) {}
public static function unknown(): self
{
return new self(RiskBand::Allow, 0, ['lookup_failed'], degraded: true);
}
}
The degraded flag exists so that "we allowed this because it was clean" and "we allowed this because the lookup failed" are not the same row in your logs. Without it, a provider outage looks like a quiet day.
The service
<?php
namespace App\Support\IpRisk;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
final class IpRiskService
{
private const ENDPOINT = 'https://api.ipgeolocation.io/v3/security';
public function __construct(
private readonly ?string $apiKey,
private readonly float $timeoutSeconds = 1.5,
private readonly int $cacheTtlHours = 6,
) {}
public function verdict(string $ip): IpRiskVerdict
{
// Private, loopback and reserved addresses are rejected by the API with a 423,
// so short-circuit them here instead of spending a request to be told no.
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return new IpRiskVerdict(RiskBand::Allow, 0, ['non_public_ip']);
}
if ($this->apiKey === null || $this->apiKey === '') {
Log::warning('IP risk lookup skipped: no API key configured.');
return IpRiskVerdict::unknown();
}
try {
return Cache::remember(
"ip-risk:{$ip}",
now()->addHours($this->cacheTtlHours),
fn (): IpRiskVerdict => $this->lookup($ip),
);
} catch (Throwable $e) {
// Cache driver problems must not take registration down with them.
Log::error('IP risk cache failure', ['ip' => $ip, 'error' => $e->getMessage()]);
return IpRiskVerdict::unknown();
}
}
private function lookup(string $ip): IpRiskVerdict
{
try {
$response = Http::timeout($this->timeoutSeconds)
->connectTimeout(1.0)
->retry(2, 100, throw: false)
->get(self::ENDPOINT, [
'apiKey' => $this->apiKey,
'ip' => $ip,
// Ask for only what the policy reads. Smaller payload, less to parse.
'fields' => implode(',', [
'security.threat_score',
'security.is_known_attacker',
'security.is_residential_proxy',
'security.is_corporate_gateway',
'security.is_tor',
'security.is_vpn',
'security.is_bot',
'security.is_known_good_bot',
'security.bot_type',
]),
]);
} catch (ConnectionException $e) {
Log::warning('IP risk lookup timed out', ['ip' => $ip, 'error' => $e->getMessage()]);
return IpRiskVerdict::unknown();
}
if ($response->failed()) {
Log::warning('IP risk lookup returned an error', [
'ip' => $ip,
'status' => $response->status(),
]);
return IpRiskVerdict::unknown();
}
$security = $response->json('security');
if (! is_array($security)) {
return IpRiskVerdict::unknown();
}
return $this->decide($security);
}
/** @param array<string, mixed> $s */
private function decide(array $s): IpRiskVerdict
{
$score = (int) ($s['threat_score'] ?? 0);
$reasons = [];
$band = match (true) {
$score >= 80 => RiskBand::Block,
$score >= 45 => RiskBand::Challenge,
$score >= 20 => RiskBand::Observe,
default => RiskBand::Allow,
};
// Attack bot types set this flag directly, so it is specific enough to act on alone.
if (($s['is_known_attacker'] ?? false) === true) {
$band = RiskBand::Block;
$reasons[] = 'known_attacker';
}
// Residential proxies are the ones worth adding friction for. Datacenter
// proxies already show up in the score. Promote from anything below
// Challenge, because a residential exit with a low score is the exact
// case the score is worst at.
if (($s['is_residential_proxy'] ?? false) === true
&& in_array($band, [RiskBand::Allow, RiskBand::Observe], true)) {
$band = RiskBand::Challenge;
$reasons[] = 'residential_proxy';
}
// One gateway IP can front an entire company. Never reject the workforce.
if (($s['is_corporate_gateway'] ?? false) === true && $band !== RiskBand::Allow) {
$band = RiskBand::Observe;
$reasons[] = 'corporate_gateway';
}
// Declared crawlers are not signing up for anything. Do not challenge them.
if (($s['is_known_good_bot'] ?? false) === true) {
$band = RiskBand::Allow;
$reasons[] = 'known_good_bot';
}
if (($s['is_vpn'] ?? false) === true) {
$reasons[] = 'vpn'; // Recorded, deliberately not acted on.
}
return new IpRiskVerdict($band, $score, $reasons);
}
}
Three things in there are load-bearing. The 1.5 second timeout replaces a default that is measured in tens of seconds, because a registration form that hangs is worse than one that lets a bad signup through. The single retry with a 100ms gap covers a blip without doubling your worst case. And every failure path returns IpRiskVerdict::unknown(), which is Allow with degraded: true, so this gate fails open.
That is a choice, not a default. Fail open and an outage means abusers get through for an hour. Fail closed and an outage means nobody can register at all. For a signup form I would take the first every time, and I would alert on degraded so I know it is happening. For a funds-transfer endpoint I would argue the opposite. Make the call explicitly and write it in the code where the next person will find it.
Register it with a bound API key. Note config() rather than env(): once php artisan config:cache runs in production, env() returns null and your gate silently stops working.
// config/services.php
'ipgeolocation' => [
'key' => env('IPGEO_API_KEY'),
],
// app/Providers/AppServiceProvider.php
use App\Support\IpRisk\IpRiskService;
public function register(): void
{
$this->app->singleton(
IpRiskService::class,
fn () => new IpRiskService(config('services.ipgeolocation.key')),
);
}
Put IPGEO_API_KEY in .env. You can grab an API key if you do not already have one; any provider returning a risk score works here, the field names just change.
Caching
The Cache::remember wrapper above is not an optimisation, it is the thing that makes this affordable to run. A registration page gets hit repeatedly by the same address during a normal signup flow, and each security lookup costs 2 credits.
Six hours is a defensible TTL. The provider refreshes security data at least twice every 24 hours, and the last_seen fields are day-granularity anyway, so caching for less than that buys you nothing and caching for a week means acting on stale intelligence. Key on the IP, not the session, because the whole point is that you are tracking the address rather than the visitor.
Use Redis if you have it. The file driver will work and will also mean your web nodes each build their own copy of the same verdicts, which is a slow leak rather than a failure.
The middleware class
<?php
namespace App\Http\Middleware;
use App\Support\IpRisk\IpRiskService;
use App\Support\IpRisk\RiskBand;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
final class ScoreSignupIp
{
public function __construct(private readonly IpRiskService $risk) {}
public function handle(Request $request, Closure $next): Response
{
$ip = $request->ip() ?? '';
$verdict = $this->risk->verdict($ip);
Log::info('signup.ip_risk', [
'ip' => $ip,
'band' => $verdict->band->value,
'score' => $verdict->score,
'reasons' => $verdict->reasons,
'degraded' => $verdict->degraded,
]);
if ($verdict->band === RiskBand::Block) {
// A vague message on purpose. Telling an attacker which signal fired
// tells them what to rotate.
return back()
->withInput($request->except(['password', 'password_confirmation']))
->withErrors(['email' => 'We could not complete this registration. Please contact support.']);
}
// Hand the band downstream so the controller can require verification
// without repeating the lookup.
$request->attributes->set('ip_risk', $verdict);
return $next($request);
}
}
The Challenge band is deliberately not handled here. Middleware decides whether the request continues; whether a new account starts in an unverified state is a controller and model concern. Passing the verdict through $request->attributes keeps the lookup to one call per request.
Registering and applying it
On Laravel 11, 12 and 13 you alias it when registering middleware in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'signup.risk' => \App\Http\Middleware\ScoreSignupIp::class,
]);
})
On Laravel 10 that is $middlewareAliases in app/Http/Kernel.php, and on 9 and earlier it is $routeMiddleware. Plenty of tutorials still show the old property names, so check which major version you are actually on before copying anything.
Then attach it to the POST route only:
Route::post('/register', [RegisteredUserController::class, 'store'])
->middleware(['guest', 'throttle:6,1', 'signup.risk']);
Not the GET. Scoring the form render doubles your lookups and stops nothing, since nobody registers by loading a page. Keep the throttle in front of it too; rate limiting and risk scoring solve different problems and you want both.
When you want geolocation in the same call
Sometimes the risk decision needs country as well, for example to compare the signup IP's country against the billing country on the card. The official PHP SDK covers /v3/ipgeo, which returns both:
composer require ipgeolocation/ipgeolocation-php-sdk
use Illuminate\Support\Facades\Log;
use Ipgeolocation\Sdk\ApiException;
use Ipgeolocation\Sdk\IpGeolocationClient;
use Ipgeolocation\Sdk\RequestTimeoutException;
use Ipgeolocation\Sdk\TransportException;
// Defaults are 10s connect and 30s read. Those are fine for a batch job and
// far too long for anything sitting in front of a form.
$client = new IpGeolocationClient([
'api_key' => config('services.ipgeolocation.key'),
'connect_timeout' => 1.0,
'read_timeout' => 1.5,
]);
try {
$response = $client->lookupIpGeolocation([
'ip' => $ip,
'include' => ['security'],
'fields' => ['location.country_code2', 'security.threat_score'],
]);
$country = $response->data->location?->country_code2 ?? null;
$score = (int) ($response->data->security?->threat_score ?? 0);
} catch (RequestTimeoutException|TransportException|ApiException $e) {
Log::warning('Combined geo and risk lookup failed', ['error' => $e->getMessage()]);
$country = null;
$score = 0;
} finally {
$client->close();
}
Note the ?-> on both accesses. Optional modules are absent rather than null-filled when they are not returned, so a non-nullable read will throw on you eventually.
For auditing the accounts you have already created rather than gating new ones, POST /v3/security-bulk takes up to 50,000 addresses in one request. That is the right tool for a retroactive sweep and the wrong one for a middleware.
What breaks the first time you run it locally
php artisan serve, submit the form, and the lookup returns HTTP 423 Locked with a message saying the IP is a bogon. That is correct behaviour: $request->ip() is 127.0.0.1 locally, and private, loopback and reserved ranges are rejected rather than scored.
The filter_var guard at the top of verdict() handles this by returning an Allow verdict before the request goes out, which is why local development works and why you are not burning lookups on 10.0.0.0/8 traffic from your own health checks.
To actually exercise the gate locally, fake the response rather than chasing a real risky IP. The test below does exactly that.
Heads up: a very aggressive timeout can produce HTTP 499 responses, which is the server noticing the client hung up first. If you see those in your logs, your timeout is too tight for your network path rather than something being wrong at the other end.
Testing the gate
You cannot tune thresholds you cannot test, and this is the part every tutorial in this space skips.
<?php
use App\Support\IpRisk\IpRiskService;
use App\Support\IpRisk\RiskBand;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
// Without this the container resolves the service with a null key and every
// lookup short-circuits to a degraded verdict, so the assertions below would
// pass for the wrong reason.
config(['services.ipgeolocation.key' => 'test-key']);
});
function fakeSecurity(array $overrides = []): void
{
Http::fake([
'api.ipgeolocation.io/v3/security*' => Http::response([
'ip' => '145.223.7.7',
'security' => array_merge([
'threat_score' => 0,
'is_known_attacker' => false,
'is_residential_proxy' => false,
'is_corporate_gateway' => false,
'is_known_good_bot' => false,
'is_vpn' => false,
], $overrides),
], 200),
]);
}
it('blocks a high score', function () {
fakeSecurity(['threat_score' => 90, 'is_known_attacker' => true]);
$verdict = app(IpRiskService::class)->verdict('145.223.7.7');
expect($verdict->band)->toBe(RiskBand::Block)
->and($verdict->reasons)->toContain('known_attacker');
});
it('challenges the middle band', function () {
fakeSecurity(['threat_score' => 60]);
expect(app(IpRiskService::class)->verdict('145.223.7.7')->band)
->toBe(RiskBand::Challenge);
});
it('does not punish a corporate gateway', function () {
fakeSecurity(['threat_score' => 85, 'is_corporate_gateway' => true]);
expect(app(IpRiskService::class)->verdict('87.58.66.106')->band)
->toBe(RiskBand::Observe);
});
it('fails open when the provider is unreachable', function () {
Http::fake(fn () => throw new ConnectionException('timeout'));
$verdict = app(IpRiskService::class)->verdict('145.223.7.7');
expect($verdict->band)->toBe(RiskBand::Allow)
->and($verdict->degraded)->toBeTrue();
});
Disable the cache in your test environment or these will pass for the wrong reason, since the second test will read the first one's stored verdict. CACHE_STORE=array in phpunit.xml is enough.
That fourth test is the one worth keeping. Fail-open behaviour is invisible in production until the day it matters, and an assertion is cheaper than finding out during an outage.
A few extra notes
IP addresses are personal data under GDPR. In a signup system where an IP can reasonably be linked to an account or user, it should generally be treated as personal data. Storing a risk verdict against it is processing, so document an appropriate lawful basis and retention period. Legitimate interests may support fraud-prevention processing, depending on the circumstances, but do not keep verdict logs forever by accident.
Watch the Observe band before you act on it. Scores between 20 and 44 are exactly where a policy tuned on someone else's traffic will be wrong. Let it run for a few weeks, join it against your confirmed-fraud data, and you will have an opinion worth encoding.
IPv6 needs no special handling here. filter_var validates IPv4 and IPv6 alike, the endpoint accepts both, and cache keys are strings. The one thing to check is that your cache key length is not truncating full v6 addresses somewhere in your stack.
Know the ceiling. Residential proxy networks rent real consumer connections, and a determined attacker will rotate through addresses that look like ordinary broadband. IP signals raise the cost of abuse. They do not end it. Device fingerprinting, email reputation and velocity checks on your own data are the next layers, and the risk verdict you are now logging is a useful input to all three.
Start at fail-open, Block at 80, Challenge at 45. Alert on the degraded counter so an outage looks like an outage instead of a quiet afternoon. Then leave it alone for a fortnight and let your own traffic tell you where the thresholds belong.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.