Rate Limiting Alone Won't Stop a Patient Attacker
@nestjs/throttler counts requests per IP. It has no idea what an account is. Here's what that gap actually looks like, and the Redis-backed lockout that closes it.
Senior Developer

@nestjs/throttler counts requests per IP address within a time window. That's the entire mechanism — no concept of accounts, passwords, or "this one person is being targeted." Point it at a login endpoint and it will correctly stop a script hammering /login a hundred times a second. It will do nothing against someone guessing a password once every few minutes, or spreading attempts across a dozen IPs, because that was never the problem it's built to solve.
Below is both halves of a real defense: the IP throttle for volume, and a small Redis-backed service that locks by email for everything the throttle misses. Every response shape, status code, and timing claim here came from actually running the requests, not from the docs — including one interaction between two timers that will quietly undo a lockout's own "fresh start" if you're not paying attention to how they relate.
The throttle, and what it actually returns
A baseline policy on every route, plus a stricter override on login specifically:
// app.module.ts — global baseline: 20 requests/60s per IP, everywhere
ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 20 }]),
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
// auth.controller.ts — login gets a stricter limit than the rest of the API
@Throttle({ default: { limit: 10, ttl: 60000 } })
@Post('login')
async login(@Body() dto: LoginDto) { /* ... */ }
Whether that override replaces the global 20/60s or stacks on top of it isn't obvious from reading the decorator, so it's worth confirming rather than guessing: it replaces. A route-level @Throttle with the same policy name (default) fully takes over for that route. Eleven rapid requests to /auth/register (no override, rides the global policy) all came back 201; the same eleven against /auth/login would have tripped its 10-request limit well before the last one.
The response headers on a request that's still under the limit:
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 9
X-RateLimit-Reset: 60
And once it's crossed:
// HTTP 429
{ "statusCode": 429, "message": "ThrottlerException: Too Many Requests" }
That's the library's real, unmodified default — not a placeholder for something cleaner. It reads like a stringified exception because that's more or less what it is, and it's worth routing through @nestjs/throttler's exception factory if this needs to match the rest of an API's error shape.
Where the throttle's job ends
None of that knows or cares who's logging in. Ten requests a minute from one IP against alice@example.com and ten requests a minute from ten different IPs, one attempt each, all against alice@example.com, look completely different to a per-IP counter — the second pattern never trips it, no matter how long it continues. That's the gap a second, IP-independent mechanism has to cover.
Locking the account, not the address
async recordFailure(email: string): Promise<FailureResult> {
const key = this.attemptsKey(email);
const attempts = await this.redis.incr(key);
if (attempts === 1) {
await this.redis.expire(key, this.windowSeconds);
}
if (attempts >= this.maxAttempts) {
await this.redis.set(this.lockKey(email), '1', 'EX', this.lockoutSeconds);
return { attempts, locked: true, retryAfterSeconds: this.lockoutSeconds };
}
return { attempts, locked: false };
}
async recordSuccess(email: string): Promise<void> {
await this.redis.del(this.attemptsKey(email), this.lockKey(email));
}
INCR against a key that doesn't exist yet starts it at 1, so there's no separate setup step for a first failure. The counter's expiry is set once — only when attempts === 1 — so a streak of failures within one window doesn't keep pushing its own deadline out; it's one streak, with one expiry, not a self-renewing one.
Both key-building helpers lowercase the email first:
private attemptsKey(email: string): string {
return `login-attempts:${email.toLowerCase()}`;
}
Skipping that normalization would open a real gap, not just a style nitpick — so rather than assume it works, this was tested directly: three failures as dana@example.com, then two more as Dana@Example.COM, a different casing entirely. The Redis counter read 5 afterward, under a single key, and a sixth attempt in yet another casing (DANA@EXAMPLE.COM) — this time with the correct password — still came back locked. Without the .toLowerCase(), those three casings would have been three separate five-strike budgets instead of one.
Two timers that look independent and aren't
The service has two separate durations: how long the failure counter itself lives (windowSeconds), and how long an actual lock lasts once triggered (lockoutSeconds). Treating them as unrelated knobs is the natural first instinct, and it's wrong — tested directly, with a 10-second window and a 6-second lock:
lock status right after the 6s lock expires: { locked: false }
attempts count at that same moment: 5
The lock is gone, but the counter — on its own, longer, 10-second clock — hasn't caught up yet. It's still sitting at 5. One more failed attempt right then doesn't start over at 1; it pushes the existing counter to 6, still over the 5-attempt threshold, and the account locks again immediately:
result of one failure right after unlock: { attempts: 6, locked: true }
So "unlocked" didn't mean "clean slate" here — it meant "one more mistake and you're back in." That might be exactly the behavior you want (harsher consequences for someone who fails again right after a lockout), but it should be a choice, not a side effect of two numbers that happened to get picked independently. Defaulting LOGIN_LOCKOUT_WINDOW_SECONDS and LOGIN_LOCKOUT_DURATION_SECONDS to the same value is what makes the out-of-the-box behavior a genuine reset: by the time the lock is gone, so is the counter it was based on. Tested with matched timers instead of mismatched ones, the positive case is exactly as boring as it should be — wait out the lock, and a correct password just works:
curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"correct-horse-battery"}'
# -> 201 { "accessToken": "..." } (no failures logged anywhere afterward)
The failure that triggers a lock doesn't announce it
try {
const user = await this.authService.validatePassword(dto.email, dto.password);
await this.loginAttemptService.recordSuccess(dto.email);
return { accessToken: this.authService.issueToken(user) };
} catch (err) {
await this.loginAttemptService.recordFailure(dto.email);
throw err; // same error whether or not this failure just triggered a lock
}
Four wrong-password attempts return four identical 401s. The fifth — the one that actually crosses the threshold — returns that same 401, not a warning. The lock only becomes visible on whatever comes next, correct password included. Confirmed end to end: four 401s, a fifth 401 that locked the account behind the scenes, and only the sixth request got the 429. Nothing in that fifth response tells anyone it was the last try, which is one less signal an attacker gets to calibrate around.
Both mechanisms, same request
# alice fails 5 times, then even the right password is rejected
curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"wrong-password"}'
# -> 401 (x5)
curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"correct-horse-battery"}'
# -> 429 {"statusCode":429,"message":"Account temporarily locked...","reason":"ACCOUNT_LOCKED"}
# bob, meanwhile, is entirely unaffected
curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" \
-d '{"email":"bob@example.com","password":"correct-horse-battery"}'
# -> 201 { "accessToken": "..." }
The lockout 429 and the throttler's 429 share a status code but not a body — "reason":"ACCOUNT_LOCKED" versus the generic ThrottlerException message — so nothing downstream has to guess which mechanism fired.
One sequencing detail if you're replicating this yourself: the IP throttle counts every call to /login in its window, not just the ones in whatever you'd mentally group as "the throttle test." Running the lockout sequence above first, then immediately sending a batch of requests to check the throttle, tripped the 429 on the third request of that batch rather than somewhere near the tenth — because the eight requests just spent on the lockout scenario were already sitting in the same 60-second window. Not a bug, just a shared counter that doesn't know your test plan has phases.
Running it
Needs Postgres and Redis; Docker is the fastest path to both:
git clone <your-repo-url> && cd rate-limiting-nestjs-demo
npm install && cp .env.example .env
docker run --name rl-postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=ratelimit_demo -p 5432:5432 -d postgres:16
docker run --name rl-redis -p 6379:6379 -d redis:7
npm run start:devThe shipped defaults (5 attempts, a 15-minute window and lock) are real production numbers, not demo placeholders, but they're still a starting point rather than a universal answer — how forgiving to be with a legitimate user who fumbles their password a few times is a judgment call specific to what's being protected. Lower LOGIN_LOCKOUT_WINDOW_SECONDS/LOGIN_LOCKOUT_DURATION_SECONDS in your own .env if you want to watch a lock expire without an actual 15-minute wait.
What's deliberately not here: the in-memory throttle storage this demo uses doesn't share counters across multiple app instances, so a horizontally-scaled deployment needs a shared store (@nestjs/throttler supports pluggable backends, Redis included) or the limit quietly stops meaning what it says. There's also no lockout notification to the account owner, and no CAPTCHA as a third layer for public-facing forms — both reasonable additions, both left out here to keep the two mechanisms that are built easy to see clearly.
The full project — this module, the lockout service, and a README with the complete setup and curl walkthrough — is in the rate-limiting-nestjs-demo repository alongside this post.
Comments (0)
Login to post a comment.