Rate limiting and login lockout in NestJS with Redis
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-Fordid 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.compareagainst a dummy hash with the same cost of 12. Response time does not reveal which emails are real. - Every failure returns the same
Invalid credentialsmessage.
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 proxyto 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
Self-hosting with Docker Compose, Caddy and a GitHub self-hosted runner
How dailerc.com runs on its own server: one origin behind Caddy, services in Docker Compose with health checks, and every commit on main deployed by a self-hosted runner with backups and rollback.
Read MoreAccepting VietQR bank transfers with webhook confirmation
The VietQR payment flow we designed for Dailerc Tools Store: an order code in the transfer content, an authenticated webhook, exact amount checks, duplicate-safe processing and a manual fallback.
Read MoreISR and on-demand revalidation in Next.js 15
How dailerc.com serves static pages fast and still updates the moment an admin edits content: tagged fetches, revalidateTag after every change, and a guarded revalidate route for deploys.
Read More