ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeTutorialRate Limiting Alone Won't Stop a Patient Attacker
Tutorial

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.

ZyVOP
ZyVOPSenior Developer
July 2, 2026
6 min read
Rate Limiting Alone Won't Stop a Patient Attacker
#Rate Limiting#brute force protection#NestJS#@nestjs/throttler#account lockout#Redis#login security#API security#Node.js#typeorm
👍1

@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 [email protected] and ten requests a minute from ten different IPs, one attempt each, all against [email protected], 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 [email protected], then two more as [email protected], a different casing entirely. The Redis counter read 5 afterward, under a single key, and a sixth attempt in yet another casing ([email protected]) — 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":"[email protected]","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":"[email protected]","password":"wrong-password"}'
# -> 401  (x5)

curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","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":"[email protected]","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:dev

The 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)

Scroll down to view and post comments...
ZyVOP
ZyVOP

Founder of Zyvop 🚀 | Building AI-driven tools & premium insights for software engineers, CTOs, and tech leaders. Obsessed with automating workflows and exploring the frontier of AI.

Subscribe to ZyVOP's Newsletter
Rate Limiting Alone Won't Stop a Patient Attacker

More from ZyVOP

View profile

Introducing Questions and Discussions: A New Way to Connect!

We are thrilled to announce a major update to how you can interact and share content on our platform! Up until now, sharing your thoughts meant writing a standa...

2 minAug 1

Scrapling: The Python Scraper That Doesn't Break When Sites Change (2026)

Scrapers break when websites change. Scrapling takes a different approach, using adaptive matching to survive renamed classes and shifting page structures. This guide covers its parser, `find_similar()`, auto-matching, fetchers, and practical Python examples for building scrapers that require less maintenance.

10 minJul 31

AI at Scale: Cost Cuts, New Tooling, and Growing Governance Rules

OpenAI’s price‑cut on GPT‑5.6, open‑source model distillation, emerging agent tooling, and a wave of contribution bans show AI moving from hype to production‑grade workloads, reshaping who wins and who must adapt.

3 minJul 31

We Added Redis to Make Things Faster. It Made Things Worse.

We added Redis to fix a slow endpoint and watched response times drop. Then came stale data, invalidation problems, traffic spikes, and database overload. Here’s what went wrong and what we changed.

7 minJul 30

Build a Web Scraping API with FastAPI, Celery & Redis (2026)

Build an asynchronous web scraping API with FastAPI, Celery, Redis, and Python. Learn how to queue scraping jobs, run dedicated workers, store results, handle retries and rate limits, monitor tasks, and deploy the complete scraping service with Docker.

11 minJul 29