Search docs

Jump between documentation pages.

Browse docs

Adaptive auto-ban (fail2ban-style)

As of 0.37.0 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.

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-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.

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

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, , , … 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 — an attacker can't dodge the ban by rotating endpoints.