← Blog

Rate limiting and login lockout in NestJS with Redis

Backend and DevOps

Two layers of protection for an API: per-IP request limits with @nestjs/throttler stored in Redis, and a temporary per-email lock after repeated failed logins that never reveals which accounts exist.

A public login endpoint will be brute-forced sooner or later. In the dailerc.com NestJS API we use two independent layers: per-IP rate limiting and a per-email login lockout. Both keep their counters in Redis, so every API process shares the same numbers.

Layer 1: per-IP limits with @nestjs/throttler

We register ThrottlerModule with the Redis storage from @nest-lab/throttler-storage-redis, reusing the cache module's Redis client. The global default is 120 requests per minute per IP. Sensitive routes get their own limits, defined in a single file:

export const LIMITS = {
  contact: { default: { limit: 5, ttl: 600_000 } },
  register: { default: { limit: 5, ttl: 3_600_000 } },
  login: { default: { limit: 10, ttl: 60_000 } },
  refresh: { default: { limit: 30, ttl: 60_000 } },
} as const

@Throttle(LIMITS.login)
@Post('login')
login(@Body() dto: LoginDto) { ... }

Find the real client IP

Behind a reverse proxy, req.ip is the proxy's address by default, so every user would share one bucket. We have exactly one proxy, Caddy, so we set app.set('trust proxy', 1). Express then reads the client address from the X-Forwarded-For header Caddy adds. Do not set trust proxy to true: a client could then send a forged header and pick any IP it likes.

Our guard extends ThrottlerGuard with three rules:

  • A request without X-Forwarded-For did not come through Caddy, so it is an internal call (for example a server-rendered page). Putting all of those in one bucket would throttle the whole website, so they are skipped.
  • WebSocket connections are not limited by this HTTP guard.
  • When Redis fails, the request is let through (fail-open) and the error is logged at a limited rate.
protected async handleRequest(props: ThrottlerRequest): Promise<boolean> {
  try {
    return await super.handleRequest(props)
  } catch (err) {
    if (err instanceof ThrottlerException) throw err
    this.logStorageError(err as Error)
    return true
  }
}

Failing open is a deliberate trade-off: for a business website, a Redis outage must never break pages or block sign-in. A system that must block no matter what should fail closed instead.

Layer 2: lock by email

Per-IP limits do not help when an attacker rotates IPs. So we also count failed logins per email: 5 failures within 15 minutes lock that email for 15 minutes. The counter is a Redis increment with a TTL, and the email is normalised (trimmed, lower-cased) before it becomes a key:

async recordFailure(scope, email) {
  const count = await this.cache.incrWithTtl(countKey(scope, email), 900)
  if (count === null || count < 5) return
  await this.cache.setJson(lockedKey(scope, email), 1, 900)
  await this.cache.delMany([countKey(scope, email)])
}

A successful login resets the counter. The lockout uses the same fail-open rule: if Redis is down, nobody is locked.

Never reveal which accounts exist

A careless lockout turns into an account-enumeration tool. We handle three points:

  • The lock is checked before the account lookup, so a locked email gets the same 429 whether or not the account exists.
  • An unknown email or a disabled account still runs one bcrypt.compare against a dummy hash with the same cost of 12. Response time does not reveal which emails are real.
  • Every failure returns the same Invalid credentials message.

Tell the two 429s apart in the UI

Both layers answer HTTP 429, but they mean different things: the IP limit clears in about a minute, the email lock lasts 15 minutes. The lockout response adds code: 'ACCOUNT_LOCKED', so the login page can show the right message instead of a generic one.

Summary

  • Keep counters in Redis so limits hold across several processes.
  • Set trust proxy to exactly the number of proxies you control.
  • Combine per-IP and per-account limits; each one stops a different attack.
  • Keep timing and error messages identical for every kind of failure.

Related Posts