# Adaptive auto-ban (fail2ban-style)

DaloyJS ships `autoBan()`, a reusable, escalating, decaying ban primitive. Where [`loginThrottle()`](/docs/security/websocket-login-throttle) 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).

**Diagram: Per-request decision**

- **Incoming request** (client) - identified by keyGenerator or trusted proxy IP
- **Already banned?** - checked in preBody, before body I/O
- **Banned -> reject** - 429 + Retry-After (or 403). Handler never runs
- **Not banned -> run** - watch the outgoing status
- **401 / 403 / 429 -> strike** - maxStrikes in windowMs -> ban banMs, doubling on repeat

A banned client is rejected before the handler runs. Otherwise the request proceeds and its outgoing status is watched: enough suspicious statuses inside the rolling window issue an escalating ban that decays once the client goes quiet.

## Quick start

```ts
import { createApp } from "@daloyjs/core";
import { autoBan } from "@daloyjs/core";

const app = createApp();

// Five 401/403/429s within 10 min -> a 15 min ban that doubles for repeat abuse.
app.use(autoBan({ trustProxyHeaders: true }));
```

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 `429`s, your own `403`s), 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).

```ts
// Ban by authenticated user id instead of IP:
app.use(
  autoBan({
    keyGenerator: (ctx) => (ctx.state.user as { id?: string })?.id,
  }),
);
```

### 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](/docs/response-cache#access-control-is-not-order-sensitive)). 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:

```ts
// Two proxies in front of Daloy: CDN -> LB -> app.
// X-Forwarded-For arrives as "client, CDN" and the client IP is 2 hops back.
app.use(autoBan({ trustedHops: 2 }));

// trustProxyHeaders: true is exactly trustedHops: 1 (single proxy in front).
// With no proxy at all, forwarded-header trust is attacker-controlled by
// definition: only enable either option behind a proxy chain you control.
```

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.

```ts
// Default. An unresolvable forwarded identity falls back to the TCP peer.
app.use(autoBan({ trustedHops: 2 }));

// Opt out: never count or ban a request whose identity cannot be resolved.
app.use(autoBan({ trustedHops: 2, onUnresolvedIdentity: "skip" }));
```

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 `401`s 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:

```ts
// Throws: trustProxyHeaders: false contradicts trustedHops: 2.
// Drop whichever one you did not mean.
app.use(autoBan({ trustProxyHeaders: false, trustedHops: 2 }));
```

### 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:

```ts
// Only believe X-Forwarded-For when the peer socket is one of YOUR proxies.
app.use(autoBan({ trustedProxies: ["10.0.0.0/8", "203.0.113.10"] }));

// Composes with trustedHops for longer chains: verify the peer, then walk 2 hops.
app.use(autoBan({ trustedProxies: ["10.0.0.0/8"], trustedHops: 2 }));
```

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 for `banMs` (default 15 min).
- With `escalate: true` (default) each *repeat* ban doubles (`banMs`, `2×`, `4×`, ...), capped at `maxBanMs` (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.

```ts
app.use(
  autoBan({
    trustProxyHeaders: true,
    windowMs: 5 * 60_000, // 5 min strike window
    maxStrikes: 10, // 10 failures before a ban
    banMs: 30 * 60_000, // 30 min base ban
    maxBanMs: 12 * 60 * 60_000, // cap escalation at 12 h
    banStatus: 403,
    message: "Access temporarily suspended",
    watchStatuses: [401, 403, 429, 422], // also count validation failures
  }),
);
```

## Observability

Wire `onBan` and `onStrike` into your logger, alerting, or an external denylist feed:

```ts
app.use(
  autoBan({
    trustProxyHeaders: true,
    onStrike: ({ key, strikes, status }) =>
      log.debug({ key, strikes, status }, "auto-ban strike"),
    onBan: ({ key, banCount, banDurationMs }) =>
      log.warn({ key, banCount, banDurationMs }, "client banned"),
  }),
);
```

## 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:

```ts
import type { AutoBanStore, AutoBanRecord } from "@daloyjs/core/auto-ban";

const redisStore: AutoBanStore = {
  async get(key) {
    const raw = await redis.get(`ban:${key}`);
    return raw ? (JSON.parse(raw) as AutoBanRecord) : undefined;
  },
  async set(key, record, ttlMs) {
    await redis.set(`ban:${key}`, JSON.stringify(record), "PX", ttlMs);
  },
  async delete(key) {
    await redis.del(`ban:${key}`);
  },
};

app.use(autoBan({ trustProxyHeaders: true, store: redisStore }));
```

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.

---

Source: https://daloyjs.dev/docs/auto-ban