Skip to main content

Quick Start

Install

# framework-agnostic core
npm install @authlock/core

# NestJS apps: the adapter + the core (stores and policy types live in the core)
npm install @nest-native/lockout @authlock/core

The Drizzle stores are opt-in — install drizzle-orm and your driver only if you use one:

npm install drizzle-orm pg # or better-sqlite3 / mysql2

The framework-agnostic core

Three calls, wired into your own login handler: check before the credential, then recordFailure or recordSuccess with the outcome.

import {LockoutManager, InMemoryLockoutStore} from '@authlock/core';

const lockout = new LockoutManager({
store: new InMemoryLockoutStore(),
limit: 5, // lock after 5 failures…
cooloffMs: 15 * 60_000, // …for 15 minutes
parameters: [['username'], ['ip']], // lock by username OR by IP
});

// 1. Pre-auth gate: reject if locked (read-only, never counts the attempt).
const gate = await lockout.check({username, ip});
if (gate.locked) {
res.setHeader('Retry-After', Math.ceil(gate.retryAfterMs! / 1000));
return res.status(429).json({error: 'too many attempts'});
}

// 2. Verify the credential however you like, then report the outcome.
if (await verifyPassword(username, password)) {
await lockout.recordSuccess({username, ip}); // clears the counters
} else {
const decision = await lockout.recordFailure({username, ip});
return res.status(decision.locked ? 429 : 401).json({error: 'invalid'});
}

Escalate repeat offenders with tiered cooloff, and pick the dimensions that can trip a lock via parameters — a lock trips when any of them reaches the limit:

const lockout = new LockoutManager({
store,
limit: 3,
cooloffMs: 60_000, // 1 minute at 3 failures…
tiers: [{atFailures: 10, cooloffMs: 60 * 60_000}], // …1 hour at 10
parameters: [['username'], ['ip'], ['ip', 'userAgent']],
whitelist: (id) => id.ip === '10.0.0.1', // never counted or locked
});

:::warning Locking by username is a DoS vector Inherent to identity-based lockout (django-axes has it too): if you lock on ['username'], anyone can lock a victim out of their own account by submitting failed logins for that username. Prefer a combination (['username', 'ip']) so an attacker must also control the victim's IP, keep a looser ['ip'] parameter for distributed guessing, and consider a softer response (CAPTCHA/delay) for the username dimension. The library locks exactly what you configure — choosing safe parameters is your call. :::

:::danger Never trust a spoofable IP The classic lockout vulnerability (django-axes, django-defender, DRF apps): if the ip you key on comes from a client-controllable header like X-Forwarded-For, an attacker rotates it to bypass IP lockout, or forges a victim's IP to lock them out. Pass the real connection address; only trust a proxy header if your proxy sets it and strips the client's. The NestJS adapter's default extractor uses req.ip (not X-Forwarded-For) — behind a proxy, configure your platform's trust proxy correctly. And never whitelist on a dimension an attacker can spoof. :::

See the Security & operations notes for identity normalization, bounding store growth, and the fail-open trade-off.

Unlocking an identity

recordSuccess clears the counters on a successful login. For an administrative unlock — a support tool, an "unlock user" button, an unlock-via-email link — call reset, which clears unconditionally (it ignores resetOnSuccess and the whitelist):

await lockout.reset({username, ip}); // core
// or, in NestJS: await this.lockout.reset({username, ip});

A shared, durable store

The in-memory store is single-process. For multiple instances (or to survive a restart) use a Drizzle store — its increment is a single atomic statement, so concurrent failed attempts across nodes count exactly once each:

import {drizzle} from 'drizzle-orm/node-postgres';
import {LockoutManager} from '@authlock/core';
import {PostgresLockoutStore, pgLockoutTable} from '@authlock/core/postgres';

// Add this table to your Drizzle schema + migration:
export const lockoutAttempts = pgLockoutTable(); // 'lockout_attempts' by default

const lockout = new LockoutManager({
store: new PostgresLockoutStore(drizzle(pool), lockoutAttempts),
limit: 5,
cooloffMs: 15 * 60_000,
parameters: [['username'], ['ip']],
});

@authlock/core/sqlite and @authlock/core/mysql follow the same shape (sqliteLockoutTable / mysqlLockoutTable). The store never opens a connection — you pass it your Drizzle handle.

NestJS

NestJS has no ambient login-failure signal, so the adapter is explicit by design. Register the module once:

import {Module} from '@nestjs/common';
import {LockoutModule} from '@nest-native/lockout';
import {InMemoryLockoutStore} from '@authlock/core';

@Module({
imports: [
LockoutModule.forRoot({
store: new InMemoryLockoutStore(), // swap for a Drizzle store in production
limit: 5,
cooloffMs: 15 * 60_000,
parameters: [['username'], ['ip']],
}),
],
})
export class AppModule {}

Guard the login route (place LockoutGuard before your auth guard) and report the outcome from the handler:

import {Controller, Post, Body, Req, UseGuards} from '@nestjs/common';
import {LockoutGuard, LockoutService} from '@nest-native/lockout';

@Controller('auth')
export class AuthController {
constructor(private readonly lockout: LockoutService) {}

@Post('login')
@UseGuards(LockoutGuard) // 429 + Retry-After if already locked
async login(@Body() dto: {username: string}, @Req() req) {
const identity = {username: dto.username, ip: req.ip};
const user = await this.verify(dto); // your credential check
if (!user) {
await this.lockout.reportFailure(identity);
throw new UnauthorizedException();
}
await this.lockout.reportSuccess(identity);
return this.issueSession(user);
}
}

With passport-local, put LockoutGuard first so a locked identity is rejected before Passport runs, and report the failure where the check fails (the strategy's validate, or a catch around AuthGuard):

@UseGuards(LockoutGuard, AuthGuard('local'))
@Post('login')
login(@Req() req) { /* report success here */ }

See the API Reference for every option, and the Samples for full runnable apps.