DEV Community

Cover image for The lockout bypass hiding in your login form
Rodrigo Nogueira
Rodrigo Nogueira

Posted on

The lockout bypass hiding in your login form

An application adds account lockout: five failed logins for a username, then a cooloff. It looks correct. It has a hole.

POST /login  { username: "Alice",  password: "..." }   // failure #1
POST /login  { username: "alice",  password: "..." }   // failure #1 (again)
POST /login  { username: "ALICE",  password: "..." }   // failure #1 (again)
POST /login  { username: "alice ", password: "..." }   // failure #1 (again)
Enter fullscreen mode Exit fullscreen mode

If the login is case-insensitive but the lockout keys on the raw string, each spelling is a separate counter. The limit is per-spelling, so the attacker never trips it. Case and trailing whitespace alone multiply the effective budget several times over; add Unicode and it is worse.

This is a small bug with a large blast radius — precisely the kind of edge a lockout implementation has to normalize away. Python has django-axes for it. The NestJS ecosystem had no equivalent for the common case — lock on the database you already run, no Redis — so @nest-native/lockout (on a framework-agnostic core, @authlock/core) fills it.

The honest constraint first

django-axes can be nearly install-and-forget because Django emits an ambient user_login_failed signal it hooks into. NestJS has no equivalent signal bus. Any library that claims otherwise is hiding wiring somewhere. So this one doesn't claim it: the integration is explicit, and the docs lead with that.

You call it from your own login handler, at the point where the outcome is known:

async login(input: LoginInput, ip: string | undefined) {
  const identity = { username: input.username, ip };

  const gate = await this.lockout.check(identity); // pre-auth: is it locked?
  if (gate.locked) throw new TooManyRequests(gate.retryAfterMs);

  const user = await this.verify(input);
  if (!user) {
    await this.lockout.reportFailure(identity); // count it
    throw new Unauthorized();
  }
  await this.lockout.reportSuccess(identity);     // reset on success
}
Enter fullscreen mode Exit fullscreen mode

Three explicit calls — check before the credential, reportFailure / reportSuccess after. There is a LockoutGuard for plain HTTP, and the same service works on tRPC, GraphQL, or a WebSocket, where a guard's body-reading extractor doesn't fit.

Closing the bypass

The opening bug is a one-line configuration once the library owns key derivation. Declare a per-dimension normalizer; it is applied to each value before the key is hashed, on every path (check, record, and reset), so the counter is shared and unlock works under any spelling:

LockoutModule.forRoot({
  store: new InMemoryLockoutStore(), // swap for a Drizzle store in production
  limit: 5,
  cooloffMs: 15 * 60_000,
  parameters: [['username'], ['ip']],
  normalize: { username: (v) => v.trim().toLowerCase() }, // ip left verbatim
});
Enter fullscreen mode Exit fullscreen mode

Normalization is the app's decision — only you know which dimensions your auth treats as equal — so it is opt-in, but applied on every path (check, record, reset) rather than left to each call site.

Three separate counters labeled Alice, ALICE, and

The security details that are easy to get wrong

Two counters side by side — a username counter, full and locked, and an ip counter, half full — both feeding into a single LOCKED state, illustrating that any configured key tripping locks the identity

  • A lock trips if any configured key trips. With [['username'], ['ip']], a username lock catches a single-target attack even from rotating IPs, and an IP lock catches spraying across many usernames.
  • Cooloff is anchored to the last failure, not the window start. Every failed attempt during a lock re-anchors it, so an attacker can't get a free burst of guesses between a base lock and the next tier. The counting window is still measured from the first failure, which bounds how long a persistent attacker can keep a victim locked out.
  • Tiered cooloff is validated as monotonic. A schedule where failing more locks for less time would let an attacker self-unlock early; the constructor rejects it (along with duplicate or non-integer thresholds).
  • Fail-open by default. If the store errors, the attempt is allowed and logged — a database blip must not lock every user out. failMode: 'closed' is available when denying during an outage is the right trade-off.
  • Credentials never reach the store. Keys are SHA-256 hashes of the identity dimensions, so a raw username never appears in a row.
  • Cross-instance counting is atomic. The Drizzle stores (SQLite / Postgres / MySQL) increment through a single atomic create-or-increment-with-window-reset, so concurrent attempts across app instances count exactly once and can never overshoot the limit unnoticed. (MySQL re-reads the row afterward, since it has no RETURNING.)

Operations

reset(identity) unlocks one identity (an admin action, an unlock-via-email link). With single-dimension parameters, reset({ username }) already clears that user across every IP. For a false-positive lockout wave, resetAll() clears every counter at once.

There is no built-in audit log: the library stores only the counters it needs. An audit trail is built from what you already have — log each attempt at your own reportFailure / reportSuccess call site, and alert on the onLockout transition hook — recording identity dimensions only, never the credential.

What it is not

  • Not a rate limiter. Request-rate limiting is @nestjs/throttler; this is failed-authentication lockout. They compose.
  • Not a proxy-header parser. The default extractor uses req.ip, not X-Forwarded-For — trusting a spoofable header is how an attacker rotates past an IP lock or forges a victim's IP to lock them out. Configure trust proxy for your topology; the extractor is a hook, not a guess.
  • Not tied to a framework. The engine is zero-dependency and runs from Express or a bare script; @nest-native/lockout is a thin DI shell over it, built on stable NestJS primitives so the same code runs on NestJS 10, 11, and 12.

Case and whitespace aren't app-level afterthoughts — they are correctness properties of the lockout key, enforced where the library owns that key.

Not affiliated with the NestJS core team or the django-axes project.

Top comments (0)