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)
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
}
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
});
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.
The security details that are easy to get wrong
-
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, notX-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. Configuretrust proxyfor 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/lockoutis 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.
- Adapter:
@nest-native/lockout· engine:@authlock/core - Docs: nest-native.dev/lockout · source: github.com/nest-native/lockout
Not affiliated with the NestJS core team or the django-axes project.


Top comments (0)