← Dev log
EduQate·auth & security·2026-07-20

A rate limiter that counted the wrong thing

NestJS ThrottlerGuard keys on req.ip by default, so every user behind one office NAT shared a single rate-limit budget on authenticated routes.

It could not have worked

A race, an ordering, one column holding two things. The shape ruled it out before the code ran.

Came up while reasoning about whether the backend was safe to cluster. NestJS's default ThrottlerGuard, with no custom tracker anywhere in the codebase, keys everything off req.ip.

Reasonable for anonymous pre-login endpoints, where there's no other identity. But it was the only key for authenticated routes too, so in production every customer on the same organisation's front-desk WiFi and every staff member behind the same office NAT shared one budget. Nothing in the code distinguished one abusive user from fifty people sharing a router.

A custom guard now keys on req.user?.id with IP as the pre-auth fallback, which meant reordering the global guard chain so auth populates req.user before the throttler runs. Verified by logging in as two accounts from one IP, hammering a protected endpoint with both, and checking the actual Redis keys: two counters, not one. Login and register still throttle by IP, since @Public() routes never populate a user.

"Who counts as one caller" is a decision a rate limiter makes silently by default, and the default assumes everyone behind a shared connection deserves to be treated as colluding.