Adaptive auto-ban (fail2ban-style)
DaloyJS ships autoBan(), a reusable, escalating, decaying ban primitive. Where loginThrottle() only protects credential-entry routes, autoBan() watches any response and temporarily bans a client that trips too many suspicious statuses (by default 401 / 403 / 429) inside a rolling window. Repeat offenders earn exponentially longer bans. The record decays once the client goes quiet, so a one-off burst is forgiven while a persistent attacker is locked out for progressively longer. It is dependency-free and runtime-portable.
Key Recommendation for Adaptive auto-ban middleware
Use adaptive auto-ban to defend critical authentication, sign-up, or high-cost public endpoints against automated brute-force attacks and scrapers. Do not rely on it as a primary defense for apps already shielded by edge-level web application firewalls (WAFs).
When & Where to Use
- ✓Protecting credential entry (login, password reset) and signup endpoints against dictionary/brute-force attacks.
- ✓Mitigating scraping attempts on public catalog endpoints or content feeds.
- ✓Limiting aggressive bot probing for vulnerable paths (e.g., searching for /.env or /wp-admin).
When & Where NOT to Use
- ✗When your hosting platform or CDN (Cloudflare, AWS WAF, Fastly) already handles IP-level rate-limiting and blocking at the edge (edge blocking is far more resource-efficient).
- ✗For endpoints consumed by trusted internal clients or automated partner services sharing the same IP address (risks banning a corporate proxy).
- ✗Behind proxies without configuring proper client IP attribution headers (can lead to banning all users on a shared proxy).
- clientIncoming requestidentified by keyGenerator or trusted proxy IP
- Already banned?checked in preBody, before body I/O
- Banned -> reject429 + Retry-After (or 403). Handler never runs
- Not banned -> runwatch the outgoing status
- 401 / 403 / 429 -> strikemaxStrikes in windowMs -> ban banMs, doubling on repeat
Quick start
Mount it globally with app.use() so it observes every route. Because it reads the outgoing status, it counts failures produced by any downstream middleware or handler (auth rejections, rate-limit 429s, your own 403s), not only its own.
Identity is mandatory
autoBan() refuses to construct unless it can identify clients: pass a keyGenerator, set trustProxyHeaders: true, or declare trustedHops. This is deliberate. A shared "global" bucket would let a single offender ban every caller at once. A request the key generator cannot attribute (returns undefined) is skipped (never counted, never banned).
When your key generator runs
autoBan enforces in the preBody phase, which is what makes it immune to mount order (see the response-cache note). Your keyGenerator is called there first, before any body is parsed and before any beforeHandle middleware has run. Key off the request line, headers, params or query and it resolves in that phase, and the ban holds wherever you mount it.
Some identities only exist later. The example above reads ctx.state.user, which a session()-style layer populates in beforeHandle. So if the preBody call returns undefined, DaloyJS calls your generator a second time in beforeHandle, when that state exists. Without the retry such a generator returned undefined forever: no key was recorded, strike accounting found nothing to attribute, and the ban silently never armed. Requests enforced by the second attempt are order-sensitive again, since beforeHandle is the phase a responseCache() hit short-circuits. Enforcing late still beats not enforcing. Returning undefined from both attempts skips the request as documented.
ctx.body is not available in either phase. The option is typed as IdentityGateContext, so reading through body is a compile error rather than a security control that quietly turns itself off at run time. The same type governs resolveIp on geoBlock(), ipRestriction(), botGuard() and ipReputation().
Spoof-resistant proxy identity
When the default key generator reads X-Forwarded-For, it keys on the rightmost entry: the one your immediate proxy actually appended. Attackers can prepend arbitrary entries to the left of that header, but they cannot touch the slots your own proxy chain wrote. This defeats both classic abuses of leftmost-IP keying: rotating a spoofed entry per attempt to evade strike accumulation, and spoofing a victim's address to get them banned.
Behind more than one proxy hop (CDN → load balancer → app), declare the chain length with trustedHops so the key comes from the slot your outermost trusted proxy wrote:
Two details worth knowing when you declare more than one hop. First, X-Real-IP is honoured only at trustedHops: 1, because that header carries exactly one hop of information and cannot express a longer chain. Past one hop, a request whose X-Forwarded-For is shorter than your declaration never traversed the topology you described (a request that reached your origin directly, skipping the CDN, for instance), so it resolves to no identity rather than to a header the caller set themselves.
Resolving to no identity is right for identity, but discarding such a request is wrong for abuse accounting: an attacker who reaches your origin directly would get unlimited strikes by omitting a header. So autoBan falls back to the immediate TCP peer address, in its own peer: keyspace. The peer cannot be spoofed (it is the socket actually talking to your server), and in exactly the direct-to-origin case that produced the bypass, the peer is the attacker, so accounting becomes precise rather than absent.
Choose "skip" only when unresolved requests are known-benign and arrive from a shared address (a load balancer that does not always set X-Forwarded-For, say, where every such request would otherwise share that balancer's single peer: bucket and a few 401s could ban the lot). Fixing the proxy configuration is the better answer. On edge runtimes that expose no peer socket there is nothing to attribute to, and the request is skipped either way. A custom keyGenerator owns its own posture. Returning undefined still means skip.
Second, trustedHops already implies proxy-header trust, so pairing it with trustProxyHeaders: false is a contradiction and throws at construction rather than silently resolving in favour of trust:
Verify the peer: trustedProxies
trustedHops answers "how many proxies are in front of me". It cannot answer "is the socket talking to me one of MY proxies". Any client that can reach your origin directly, through a misconfigured firewall, a leaked origin IP, or a neighbouring internal service, can still hand you any X-Forwarded-For it likes: rotate entries to shed strikes, or spoof a victim's address to get them banned. trustedProxies closes that at the framework layer by checking the immediate TCP peer, the one thing a remote caller cannot spoof, against a CIDR allowlist before a single forwarded header is believed:
A peer outside the allowlist gets no forwarded identity at all: the spoofed header is ignored, and autoBan falls back to the peer: bucket described above, which in exactly this direct-to-origin case is the attacker themselves. On peer-less edge platforms verification fails closed. Declaring trustedProxies implies proxy-header trust (one hop unless trustedHops says otherwise), so pairing it with trustProxyHeaders: false throws at construction, as do an empty list and malformed CIDR entries. The same option exists on rateLimit(), loginThrottle(), concurrencyLimit(), geoBlock(), ipRestriction(), ipReputation(), and botGuard().
Honest proxies see no behaviour change: requests arriving through a listed proxy resolve exactly as before. The allowlist only bites traffic that bypassed your proxy chain, which is precisely the traffic whose headers were never trustworthy.
How escalation & decay work
- Each watched response is a strike. Strikes accumulate inside
windowMs(default 10 min) and decay when the window passes. - Reaching
maxStrikes(default 5) issues a ban forbanMs(default 15 min). - With
escalate: true(default) each repeat ban doubles (banMs,2×,4×, ...), capped atmaxBanMs(default 24 h), for as long as the record stays alive. - Once the client stops tripping statuses, the record expires and the escalation counter resets: the ban decays.
Responses
A banned request is rejected in beforeHandle before the handler runs. By default it returns 429 Too Many Requests with a Retry-After header and Cache-Control: no-store. Set banStatus: 403 for a 403 Forbidden with your own message instead.
Observability
Wire onBan and onStrike into your logger, alerting, or an external denylist feed:
Pluggable store (multi-instance)
The default store is in-memory and single-process. For a horizontally-scaled deployment, implement AutoBanStore (mirroring the rateLimit() store contract) against Redis or another shared backend so a ban applies across every instance:
Implementations must treat an entry past its ttlMs as absent so bans and escalation decay automatically. To lift a ban manually, call store.delete(key).
Sharing across route groups
Every autoBan() with the same groupId (default "auto-ban") shares one in-memory store, so a client banned on one group is banned on all of them, so an attacker can't dodge the ban by rotating endpoints.