Skip to content

Fail2Ban & Allow2Ban

Fail2Ban and Allow2Ban are Phirewall's automatic banning mechanisms. They monitor request patterns and temporarily ban clients that exceed configurable thresholds, the primary defense against brute force attacks, credential stuffing, and persistent scanners.

Fail2Ban

Fail2Ban counts requests that match a filter condition. When the count for a given key reaches the threshold within the observation period, the key is banned.

How It Works

text
Request --> Is key already banned? --> Yes --> 403 Forbidden
                    |
                    No
                    |
                    v
            Does filter match? --> No --> Continue to throttle rules
                    |
                    Yes
                    |
                    v
            Increment failure counter
                    |
                    v
            Counter >= threshold? --> No --> Continue to throttle rules
                    |
                    Yes
                    |
                    v
            BAN key for configured duration --> 403 Forbidden
  1. A filter closure checks each incoming request for a condition (e.g., a POST to /login)
  2. Matches are counted per key (e.g., IP address) within a time period
  3. When the count reaches the threshold, the key is banned for a configurable duration (e.g., threshold: 5 bans on the 5th matching request; the same request that brings the counter to 5 is itself blocked)
  4. Banned keys receive 403 Forbidden immediately, without further rule evaluation

Configuration

php
$config->fail2ban->add(
    string $name,
    int $threshold,
    int $period,
    int $ban,
    Closure $filter,
    ?Closure $key = null
): Fail2BanSection
ParameterTypeDescription
$namestringUnique rule identifier
$thresholdintNumber of filter matches that triggers the ban (must be >= 1). The Nth matching request is itself banned (matching rack-attack maxretry semantics).
$periodintTime window for counting matches in seconds (must be >= 1)
$banintBan duration in seconds (must be >= 1)
$filterClosurefn(ServerRequestInterface): bool, return true to count as a match
$key?Closurefn(ServerRequestInterface): ?string, return key to track, or null to skip. When the whole argument is omitted, defaults to the client IP from the Config's IP resolver (Config::setIpResolver(), typically KeyExtractors::clientIp($proxy)), falling back to KeyExtractors::ip() (REMOTE_ADDR). The resolver is read per request, so it can be set before or after the rule.

WARNING

Fail2Ban filters evaluate the incoming request before the handler runs. The filter can only inspect request data (path, method, headers, query parameters). It cannot see the application's response. To ban based on application outcomes (like actual failed logins), use the Request Context API instead.

Login Brute Force Protection

The most common use case: ban IPs that repeatedly POST to the login endpoint.

php
use Flowd\Phirewall\KeyExtractors;

// Ban after 5 login attempts in 5 minutes, for 1 hour
$config->fail2ban->add('login-brute-force',
    threshold: 5,
    period: 300,       // 5 minute observation window
    ban: 3600,         // 1 hour ban
    filter: fn($req) => $req->getMethod() === 'POST'
        && $req->getUri()->getPath() === '/login',
);

TIP

Counting every POST to /login is simpler and works well for most applications. Legitimate users who log in successfully within the threshold are unaffected. Set a generous enough threshold (5-10) so users who mistype their password are not banned.

Credential Stuffing Defense

Credential stuffing uses stolen username/password lists from data breaches. Defend against it by combining IP-based banning with user-based throttling:

php
use Flowd\Phirewall\KeyExtractors;

// Per-IP tracking: ban after 10 login attempts in 10 minutes
$config->fail2ban->add('credential-stuffing-ip',
    threshold: 10,
    period: 600,
    ban: 7200,         // 2 hour ban
    filter: fn($req) => $req->getMethod() === 'POST'
        && $req->getUri()->getPath() === '/login',
);

// Per-username throttle: 5 attempts per 5 minutes per username
$config->throttles->add('credential-stuffing-user',
    limit: 5,
    period: 300,
    key: function ($req): ?string {
        if ($req->getMethod() !== 'POST' || $req->getUri()->getPath() !== '/login') {
            return null;
        }
        $body = (array) $req->getParsedBody();
        $username = $body['username'] ?? $body['email'] ?? null;
        return $username ? 'user:' . strtolower(trim($username)) : null;
    }
);

// Burst detection: 3 login attempts in 10 seconds = suspicious
$config->throttles->add('login-burst',
    limit: 3,
    period: 10,
    key: function ($req): ?string {
        if ($req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login') {
            return $req->getServerParams()['REMOTE_ADDR'] ?? null;
        }
        return null;
    }
);

This three-layer strategy defends against different attack speeds:

  • Fail2Ban catches persistent IP-based attacks and bans for hours
  • Per-username throttle prevents attacks that rotate IPs but target the same account
  • Burst detection catches rapid-fire automated tools immediately

API Signature Abuse

Ban clients sending invalid API signatures. A middleware running before Phirewall validates the signature and records the outcome and the verified client id on the request as attributes:

php
// The Fail2Ban rule reads the attributes the prior middleware set
$config->fail2ban->add('api-abuse',
    threshold: 3,
    period: 120,       // 2 minute window
    ban: 900,          // 15 minute ban
    filter: fn($req) => $req->getAttribute('apiSignatureValid') === false,
    // Key on the verified client id (an internal identifier, not the raw API
    // secret), falling back to the client IP when the request is unauthenticated.
    key: fn($req): ?string =>
        $req->getAttribute('apiClientId') ?? ($req->getServerParams()['REMOTE_ADDR'] ?? null),
);

Persistent Scanner Blocking

Ban IPs that persistently probe your application:

php
use Flowd\Phirewall\KeyExtractors;

$config->fail2ban->add('persistent-scanner',
    threshold: 10,     // 10 matched requests
    period: 60,        // in 1 minute
    ban: 86400,        // 24 hour ban
    filter: fn($req) => true,
);

WARNING

The filter fn($req) => true counts every request that reaches the Fail2Ban layer. Because safelisted and blocklisted requests never reach Fail2Ban, this effectively counts requests that passed safelists and blocklists but are still suspicious. Use with care: this is a broad filter.

Post-Handler Signaling with RequestContext

Standard Fail2Ban filters run before your application handler, so they can only inspect the incoming request. The RequestContext API solves this by letting your handler signal failures after it has processed the request, for example after verifying credentials against a database.

How It Works

text
Request
   |
   v
Middleware (pre-handler)
   |
   ├── Firewall evaluates safelists, blocklists, fail2ban, throttles
   ├── Attaches RequestContext to request attribute
   |
   v
Your Handler
   |
   ├── Checks credentials, validates input, etc.
   ├── On failure: $context->recordFailure('rule-name')
   |
   v
Middleware (post-handler)
   |
   ├── Reads recorded signals from RequestContext
   ├── Increments fail2ban / allow2ban counters per signal
   |
   v
Response

Setup

Configure a fail2ban rule with a filter that always returns false. The filter will never match pre-handler; all counting happens via recordFailure():

php
use Flowd\Phirewall\Config;
use Flowd\Phirewall\KeyExtractors;
use Flowd\Phirewall\Store\InMemoryCache;
use Psr\Http\Message\ServerRequestInterface;

$config = new Config(new InMemoryCache());

$config->fail2ban->add(
    name: 'login-failures',
    threshold: 3,
    period: 300,       // 5 minute window
    ban: 3600,         // 1 hour ban
    filter: fn(ServerRequestInterface $req): bool => false,
);

Recording Failures in Your Handler

Inside your request handler, retrieve the RequestContext from the request attribute and call recordFailure(). The second argument is optional; when omitted, the firewall reuses the rule's own keyExtractor against this request, so the handler doesn't need to repeat the IP/header/etc. extraction:

php
use Flowd\Phirewall\Context\RequestContext;

class LoginController
{
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $username = $request->getParsedBody()['username'] ?? '';
        $password = $request->getParsedBody()['password'] ?? '';

        if (!$this->auth->verify($username, $password)) {
            // Signal the failure; the firewall extracts the key from
            // the rule's own keyExtractor against this request.
            $context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME);
            $context?->recordFailure('login-failures');

            return new Response(401, [], 'Invalid credentials');
        }

        return new Response(200, [], 'Welcome!');
    }
}

Pass an explicit second argument only when the handler knows a discriminator the firewall cannot derive from the request alone (e.g. a user id looked up in a session store):

php
$context?->recordFailure('login-failures', $userIdFromSession);
MethodDescription
$context->recordFailure(string $ruleName, ?string $key = null)Record a fail2ban failure signal. $ruleName must match a configured fail2ban rule name. $key is optional; when omitted, the rule's own key extractor resolves the discriminator from the current request, so the handler does not need to know whether the rule keys on IP, header, or anything else.
$context->recordHit(string $ruleName, ?string $key = null)Counterpart for allow2ban rules; same shape, routed through the allow2ban evaluator. See Request Context.
$context->getResult()Returns the FirewallResult from the pre-handler evaluation
$context->hasRecordedSignals()Whether any signals have been recorded
$context->getRecordedSignals()Returns all recorded RecordedSignal objects

TIP

Use the null-safe operator ($context?->recordFailure(...)) so your handler works safely both with and without the middleware in the stack, useful in unit tests where the middleware may not be present.

Why Use RequestContext?

ApproachProsCons
Pre-handler filter (path/method)Simple, no handler changesCounts all attempts, not just failures
Prior middleware + headerCan signal actual failuresRequires extra middleware, complex flow
RequestContext APISignals actual failures from handlerRequires handler integration

RequestContext is the most accurate approach because it only increments the fail2ban counter when your application confirms a failure (wrong password, invalid token, etc.). Successful logins are never counted.

Allow2Ban

Allow2Ban is a dedicated section ($config->allow2ban) with its own API. It is the inverse of Fail2Ban: instead of counting only filtered "bad" requests, it counts every request for a given key and bans once the count reaches the threshold. Think of it as "n requests allowed, then you're out": with threshold: n, the nth request itself is the one that triggers and is blocked.

How It Works

text
Request --> Is key already banned? --> Yes --> 403 Forbidden
                    |
                    No
                    |
                    v
            Increment request counter
                    |
                    v
            Counter >= threshold? --> No --> Allow (pass to handler)
                    |
                    Yes
                    |
                    v
            BAN key for configured duration --> 403 Forbidden

There is no filter; every request matching the key extractor is counted.

Configuration

php
$config->allow2ban->add(
    string $name,
    int $threshold,
    int $period,
    int $banSeconds,
    ?Closure $key = null
): Allow2BanSection
ParameterTypeDescription
$namestringUnique rule identifier
$thresholdintNumber of requests that triggers the ban (must be >= 1). The Nth request is itself banned (matching rack-attack maxretry semantics).
$periodintTime window for counting requests in seconds (must be >= 1)
$banSecondsintBan duration in seconds (must be >= 1)
$key?Closurefn(ServerRequestInterface): ?string, return key to track, or null to skip. When omitted, defaults to the client IP from the Config's IP resolver (see Fail2Ban's $key above).

TIP

Note the parameter name difference: Fail2Ban uses $ban, Allow2Ban uses $banSeconds. Both accept duration in seconds.

High-Volume Request Banning

Ban any IP that sends an excessive number of requests:

php
use Flowd\Phirewall\KeyExtractors;

// Ban any IP that sends more than 100 requests in 60 seconds, for 1 hour
$config->allow2ban->add(
    name: 'high-volume-ban',
    threshold: 100,
    period: 60,
    banSeconds: 3600,
);

API Key Abuse Protection

Ban API keys that exceed expected usage. Unlike rate limiting (which returns 429 and lets the client retry), Allow2Ban bans the key entirely, a stronger response for abuse:

php
// Ban any client IP that makes more than 1000 requests in 60 seconds.
$config->allow2ban->add(
    name: 'api-volume-abuse',
    threshold: 1000,
    period: 60,
    banSeconds: 300,   // 5 minute ban
);

Header keys are client-controlled

A throttle, fail2ban, or allow2ban rule keyed on a request header (X-Api-Key, X-User-Id, …) is only as trustworthy as that header. A client can rotate or drop the header to land in a fresh counter on every request and never reach the threshold (a trivial bypass). Key such rules on a value the client cannot freely change: the client IP (via KeyExtractors::clientIp() with a TrustedProxyResolver), the authenticated principal your auth layer sets after verifying it, or a composite of both. When you must key on a credential-bearing header, use KeyExtractors::hashedHeader('X-Api-Key'): the raw value otherwise reaches the ban registry and event payloads (and your logs) in cleartext.

Unauthenticated Endpoint Abuse

Ban clients that repeatedly access authenticated endpoints without credentials:

php
use Flowd\Phirewall\KeyExtractors;

// Ban IPs making more than 20 unauthenticated API requests in 5 minutes
$config->allow2ban->add(
    name: 'unauth-api-abuse',
    threshold: 20,
    period: 300,
    banSeconds: 1800,  // 30 minute ban
    key: function ($req): ?string {
        // Only count unauthenticated requests to API endpoints
        if ($req->getHeaderLine('Authorization') === ''
            && str_starts_with($req->getUri()->getPath(), '/api/')) {
            return $req->getServerParams()['REMOTE_ADDR'] ?? null;
        }
        return null;
    },
);

Fail2Ban vs. Allow2Ban

AspectFail2BanAllow2Ban
Section$config->fail2ban$config->allow2ban
FilterRequired: only matching requests are countedNo filter: all requests for the key are counted
TriggerRepeated "bad" requests matching the filterExceeding a total request volume
Use caseBrute force, credential stuffing, scanner blockingVolume abuse, DDoS mitigation, API abuse
EventFail2BanBannedAllow2BanBanned
Ban parameter$ban$banSeconds

Managing Bans

Flowd\Phirewall\Http\Firewall is the supported runtime-management entry point. Construct it with the same Config your middleware uses; all state lives in the Config cache, so every Firewall over the same Config shares bans and counters.

php
use Flowd\Phirewall\BanType;
use Flowd\Phirewall\Http\Firewall;

$firewall = new Firewall($config);

// Is a key currently banned? BanType is REQUIRED (no default).
$firewall->isBanned('login-failures', $ip, BanType::Fail2Ban);
$firewall->isBanned('high-volume-ban', $ip, BanType::Allow2Ban);

// Lift a specific fail2ban ban (also clears its fail counter).
$firewall->resetFail2Ban('login-failures', $ip);

// Clear a throttle counter.
$firewall->resetThrottle('api', $ip);

// Clear the whole cache instance (counters, bans, tracking).
$firewall->resetAll();

isBanned() requires an explicit BanType because allow2ban and fail2ban store their bans under distinct cache keys, so an implicit default would silently answer for the wrong category:

php
enum BanType: string
{
    case Allow2Ban = 'allow2ban';
    case Fail2Ban = 'fail2ban';
}

Notes:

  • For multi() throttle sub-rules, reset each window individually (for example 'api:1s' and 'api:60s'); for dynamic-period rules, pass the :p{period} suffix.
  • resetAll() calls cache->clear() and wipes the entire cache instance, so give phirewall a dedicated cache (or key-prefixed namespace) if you share Redis/APCu with your application.
  • All keys are normalized through the discriminator normalizer, so lookups match regardless of input casing.

Events

When a key is banned, an event is dispatched through your PSR-14 event dispatcher. Fail2Ban and Allow2Ban each dispatch their own event type.

Fail2BanBanned

php
use Flowd\Phirewall\Events\Fail2BanBanned;

// Event properties
$event->rule;           // string - Rule name
$event->key;            // string - Banned key (e.g., IP address)
$event->threshold;      // int - Configured threshold
$event->period;         // int - Observation window (seconds)
$event->banSeconds;     // int - Ban duration (seconds)
$event->count;          // int - Failure count that triggered the ban
$event->serverRequest;  // ServerRequestInterface

Allow2BanBanned

php
use Flowd\Phirewall\Events\Allow2BanBanned;

// Event properties (same structure as Fail2BanBanned)
$event->rule;           // string - Rule name
$event->key;            // string - Banned key
$event->threshold;      // int - Configured threshold
$event->period;         // int - Observation window (seconds)
$event->banSeconds;     // int - Ban duration (seconds)
$event->count;          // int - Request count that triggered the ban
$event->serverRequest;  // ServerRequestInterface

Alerting on Bans

php
use Flowd\Phirewall\Config;
use Flowd\Phirewall\Events\Fail2BanBanned;
use Flowd\Phirewall\Events\Allow2BanBanned;
use Psr\EventDispatcher\EventDispatcherInterface;

$dispatcher = new class implements EventDispatcherInterface {
    public function dispatch(object $event): object
    {
        if ($event instanceof Fail2BanBanned) {
            error_log(sprintf(
                '[PHIREWALL] Fail2Ban: IP %s banned (rule: %s, failures: %d, ban: %ds)',
                $event->key,
                $event->rule,
                $event->count,
                $event->banSeconds,
            ));
        }

        if ($event instanceof Allow2BanBanned) {
            error_log(sprintf(
                '[PHIREWALL] Allow2Ban: key %s banned (rule: %s, requests: %d, ban: %ds)',
                $event->key,
                $event->rule,
                $event->count,
                $event->banSeconds,
            ));
        }

        return $event;
    }
};

$config = new Config($cache, $dispatcher);

Use events to:

  • Send Slack/email alerts when a key is banned
  • Log bans to your monitoring system (see Observability)
  • Mirror bans to infrastructure adapters (e.g., Apache .htaccess)
  • Push bans to a WAF or external firewall

Combining Fail2Ban with Other Layers

Fail2Ban and Allow2Ban work best as part of a layered defense:

php
use Flowd\Phirewall\KeyExtractors;

// Layer 1: Safelist trusted traffic
$config->safelists->add('health', fn($req) => $req->getUri()->getPath() === '/health');

// Layer 2: Blocklist known bad actors
$config->blocklists->knownScanners();

// Layer 3: Fail2Ban for brute force (counts POST to /login)
$config->fail2ban->add('login',
    threshold: 5, period: 300, ban: 3600,
    filter: fn($req) => $req->getMethod() === 'POST'
        && $req->getUri()->getPath() === '/login',
);

// Layer 4: Allow2Ban for volume abuse
$config->allow2ban->add('volume-abuse',
    threshold: 200, period: 60, banSeconds: 1800,
);

// Layer 5: Rate limiting as backstop
$config->throttles->add('global',
    limit: 100, period: 60,
);

Best Practices

  1. Use specific filters. A broad filter like fn() => true can lead to false bans. Prefer precise filters tied to specific request characteristics (path, method, headers).

  2. Set reasonable thresholds. Too low and you risk banning legitimate users. Too high and attackers have more attempts. Start with 5-10 for login protection, 50-200 for Allow2Ban volume limits.

  3. Consider ban duration carefully. Short bans (5-15 minutes) deter casual attackers while minimizing impact on legitimate users. Long bans (1-24 hours) are better for persistent automated attacks.

  4. Combine with rate limiting. Even before the ban threshold is reached, rate limiting slows down attackers. Use throttles as a softer first response (429) and bans as the hard response (403).

  5. Monitor with events. Always set up logging or alerting for Fail2BanBanned and Allow2BanBanned events so you know when bans are occurring and can detect false positives.

  6. Use RequestContext for accuracy. When you need to ban based on actual application failures (not just request patterns), use the RequestContext API to signal failures from your handler.

  7. Use infrastructure mirroring. For the most effective defense, mirror bans to Apache .htaccess or your web server so banned IPs are blocked before reaching PHP. See Infrastructure Adapters.

  8. Choose the right mechanism. Use Fail2Ban when you need a filter to detect specific bad behavior. Use Allow2Ban when you want a blanket volume limit with a ban (not just rate limiting).