API Reference
@authlock/core
The engine
| Export | Kind | Purpose |
|---|---|---|
LockoutManager | class | check() (read-only pre-auth gate), recordFailure(), recordSuccess(), reset() (unlock one identity), resetAll() (unlock everyone), pruneExpired(); multi-key evaluation + onLockout hook |
InMemoryLockoutStore | class | single-instance store, ships in the box |
deriveKeys | function | resolve an identity to its storage keys (sha256 of the dimension/value pairs — raw values never reach a store) |
cooloffFor / effectiveWindowMs / evaluateRecord | functions | the pure policy maths, exported for custom stores and tooling |
VERSION | const | the package version |
Types
| Type | Purpose |
|---|---|
Identifiers | the identity dimensions (username, ip, userAgent, custom…) |
LockoutParameter | one dimension combination that can trip a lock, e.g. ['ip', 'userAgent'] |
FailureRecord | a stored counter: {key, failures, firstFailureAt, lastFailureAt} — lock state is derived, never stored. The window is measured from firstFailureAt; the cooloff from lastFailureAt. |
CooloffTier | {atFailures, cooloffMs} — escalating cooloff by failure count |
FailMode | 'open' (allow + log on store error, default) or 'closed' (deny) |
LockoutPolicy | limit, cooloff, window, tiers, parameters, whitelist, resetOnSuccess, failMode |
LockoutDecision | {locked, retryAfterMs, trippedParameter} |
LockoutManagerOptions | the policy plus store, onLockout, logger, and an injectable now clock |
LockoutStore | the persistence seam: increment / get / clear / clearExpired (each sync or async) |
Policy options
| Option | Meaning |
|---|---|
limit | failures allowed before a key locks (locks at exactly limit) |
cooloffMs | base lock duration once locked |
windowMs? | failure-counting window; defaults to the effective cooloff |
tiers? | escalating cooloff by failure count, e.g. [{atFailures: 10, cooloffMs: 3_600_000}] |
parameters | dimension combinations to evaluate; a lock trips if any trips |
normalize? | Record<string, (value) => string> — per-dimension normalizers applied before hashing (case/whitespace-bypass defence) |
whitelist? | (id) => boolean | Promise<boolean> — identities never counted or locked |
resetOnSuccess? | clear failures on success (default true) |
failMode? | 'open' (default) or 'closed' |
Store subpaths
| Import | Contents |
|---|---|
@authlock/core/drizzle | all three stores + table factories (needs drizzle-orm, no driver) |
@authlock/core/postgres | PostgresLockoutStore + pgLockoutTable() |
@authlock/core/sqlite | SqliteLockoutStore + sqliteLockoutTable() |
@authlock/core/mysql | MysqlLockoutStore + mysqlLockoutTable() |
Every dialect uses the same table shape — key (primary key), failures, and
first_failure_at (a numeric epoch in ms) — and the same semantics: the
increment is a single atomic create-or-increment-with-window-reset
statement, so concurrent attempts across app instances count exactly once each.
Postgres and SQLite read the result back with RETURNING; MySQL (which has no
RETURNING) re-selects the row, which can only ever report an equal-or-higher
count — never an undercount an attacker could slip through.
drizzle-orm is an optional peer: the root @authlock/core import pulls
no Drizzle at all and stays zero-dependency.
@nest-native/lockout
| Export | Kind | Purpose |
|---|---|---|
LockoutModule | dynamic module | forRoot(options) / forRootAsync({useFactory, inject, imports}); global by default |
LockoutGuard | CanActivate | reject-if-locked, applied before authentication — HTTP 429 + Retry-After (Express and Fastify responses) |
LockoutService | provider | check() / reportFailure() / reportSuccess() / reset() / resetAll() — the explicit call site for your login handler and admin unlock |
defaultExtractor | function | username from the body, ip from req.ip, userAgent from the header — deliberately no X-Forwarded-For trust |
LOCKOUT_MANAGER / LOCKOUT_OPTIONS | tokens | Symbol.for DI tokens (the manager is injectable directly) |
LockoutModuleOptions | type | everything LockoutManagerOptions takes, plus extractor and isGlobal |
IdentifierExtractor | type | (context: ExecutionContext) => Identifiers |
VERSION | const | the adapter version |
The adapter builds only on stable Nest primitives (CanActivate,
DynamicModule, HttpException, ExecutionContext) and supports NestJS
10, 11, and 12.
:::tip Identity extraction is your trust decision
The default extractor reads req.ip — the connection address. Behind a proxy,
configure your platform's trust proxy (or supply your own extractor) so the
IP dimension reflects a source you actually trust; the library never reads
proxy headers for you.
:::
Security & operations
- Do not trust
X-Forwarded-For. This is the vulnerability class that hit django-axes and other tools: if the IP you key on comes from a spoofable header, an attacker can rotate it to bypass IP lockout, or forge a victim's IP to lock them out. The default extractor usesreq.ip, notX-Forwarded-For— behind a proxy, configuretrust proxysoreq.ipreflects a source you trust. Because a lock trips if any parameter trips, a['username']parameter still catches a single-target brute force even when the IP is spoofable. And neverwhiteliston a dimension an attacker can forge. - Normalize identity dimensions.
Alice,alice, andalicehash to three different counters — on a case-insensitive login an attacker bypasses the limit by varying case or whitespace. Set a per-dimensionnormalizemap (e.g.{ username: (v) => v.trim().toLowerCase() }); it is applied before hashing on every path (check / record / reset). Or normalize in your extractor. - Bound store growth. Every distinct identity creates a record. Schedule
pruneExpired()(it drops records past their window, capping the store to identities active withinwindowMs), put a rate limiter (@nestjs/throttler) in front, and use a Drizzle store (not in-memory) for hostile or multi-instance deployments. - fail-open vs. availability. With the default
failMode: 'open', a store error allows the attempt and logs — so brute-force protection is off while the store is down. UsefailMode: 'closed'to deny during an outage instead. - Bulk unlock (incident response).
resetAll()clears every counter — the "unlock everyone" button for a false-positive lockout wave. To unlock a single identity usereset(id); note that with single-dimension parameters ([['username'], ['ip']])reset({ username })already clears that username's lock across every IP (the['ip']parameter is skipped when no IP is given). Unlocking one identity across a combination parameter ([['username', 'ip']]) is not supported — the store is keyed by a one-way hash, so there is no way to enumerate "every IP this username is locked on" without keeping a reverse index of raw dimensions, which would defeat the property that credentials never reach the store. UseresetAll()for that case.
Audit logging
Some deployments need a record of failed logins and lockouts — for forensics, compliance, or alerting. authlock stores only the counters it needs for the lock decision (hashed keys, no raw identities), so it is not an audit log. But you don't need it to be one: an audit trail is built from what you already have.
Log each attempt at your own call site. You already call reportFailure /
reportSuccess (or the core recordFailure / recordSuccess) from your login
handler — that is the natural, complete place to record every attempt, with the
outcome you already know. There is deliberately no per-failure hook in the
engine: it would only tell you what your handler already knows, one layer
removed.
async login(input: LoginInput, ip: string | undefined) {
const identity = { username: input.username, ip };
const gate = await this.lockout.check(identity);
if (gate.locked) {
this.audit.record({ event: 'login.locked', ...identity }); // dimensions only
throw new TooManyRequests();
}
const user = await this.verify(input);
if (!user) {
await this.lockout.reportFailure(identity);
this.audit.record({ event: 'login.failed', ...identity });
throw new Unauthorized();
}
await this.lockout.reportSuccess(identity);
this.audit.record({ event: 'login.ok', username: user.username, ip });
}
Alert on lock transitions with onLockout. The hook fires once when an
attempt first trips a key over the limit (and on each tier escalation) — the
right signal for "an account is now locked" alerting, without logging every
failure yourself:
new LockoutManager({
// ...
onLockout: (id, decision) =>
this.audit.record({ event: 'lockout', ...id, retryAfterMs: decision.retryAfterMs }),
});
Surface store failures with logger. It is called (error, context)
whenever a store operation throws — wire it to your logger so a degraded store
(which, under the default failMode: 'open', silently disables protection) is
visible.
Never log the credential. Record only the identity dimensions used for the lock decision (username, IP, user-agent) and the outcome — never the password or token. The engine already keeps credentials out of the store by hashing; your audit log must uphold the same rule.