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 beforeHandle
- 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-limit429s, your own 403s), not just its own.
Identity is mandatory
autoBan() refuses to construct unless it can identify clients: pass a keyGenerator or set trustProxyHeaders: true. 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.
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.