← All posts
Deep dive #nestjs#auth#rate-limiting#jwt#debugging

A brute-force rate limit on /auth/me logged users out — then blocked re-login

Putting the whole auth controller behind a strict 10/60s throttle quietly caught /auth/me too — the endpoint the SPA hydrates on every route mount. A few clicks exhausted the budget, the 429 read as 'logged out', and the same spent budget then 429'd the re-login. The endpoints that read or rotate a cookie aren't the brute-force surface; only credential entry is.

A user reported their admin session “drops after a few clicks, and then I can’t log back in.” The first pass closed it as not a code bug — “dev server was probably mid-restart, hard-refresh and it’s fine.” That was wrong, and the wrongness is the interesting part: the symptom was intermittent and timing-dependent, which is exactly what a real bug looks like when you don’t yet see the mechanism.

The mechanism: the entire auth controller carried a deliberately strict brute-force throttle.

@Controller("auth")
@Throttle({ default: { limit: 10, ttl: 60_000 } }) // 10 req / 60s per IP
export class AuthController { ... }

That cap is correct for the credential-entry surface — login, register, OTP, password reset. It is the wrong cap for /auth/me, which lives on the same controller and inherits the same decorator. /auth/me is the endpoint the SPA hits to hydrate the session on every route mount (and, thanks to a double-fire on load, often twice). So a normal browsing session — click around the admin a handful of times — spends the 10-request budget in under a minute. Request eleven comes back 429.

The frontend, not unreasonably, treated a failed /auth/me as “no valid session” and logged the user out. Worse: the throttle is per IP per endpoint, and the budget was already spent, so the immediate attempt to log back in also hit 429. Hence the full report — drops after a few clicks, then can’t get back in. Two symptoms, one cause.

The fix is to recognize that reading or rotating a cookie is not a brute-force surface. Only proving a credential is. So /me, /refresh, and /logout override the strict cap back to the generous global rate; the strict cap stays exactly where it belongs:

const RELAXED_THROTTLE = { default: { limit: 100, ttl: 60_000 } };

@Controller("auth")
@Throttle({ default: { limit: 10, ttl: 60_000 } }) // brute-force surface only
export class AuthController {
  @Post("refresh") @Throttle(RELAXED_THROTTLE) refresh() { ... }
  @Post("logout")  @Throttle(RELAXED_THROTTLE) logout()  { ... }
  @Get("me")       @Throttle(RELAXED_THROTTLE) me()      { ... }
  // login / register / OTP / reset keep the strict 10/60s
}

Three things I’m taking away from this:

  • A controller-wide security decorator scopes by file layout, not by threat model. @Throttle on the class swept in every handler that happened to live there. The blast radius of “put the rate limit on the controller” was an endpoint hit on every page load — which is the opposite of a brute-force target.
  • “Intermittent, fixes itself on refresh” is not evidence of an environment problem. It’s the signature of a budget/quota/race bug. Closing it as “server was probably down” felt reasonable and was completely wrong; a refresh “fixed” it only because it reset the 60-second window.
  • A false logout and a re-login lockout can be the same bug. Per-IP-per-endpoint limits mean the action that fails and the recovery you attempt are drawing from one shared, already-empty bucket.

Related fixes in the same pass reinforced the theme: hydrate the session once (single-flight + a hydrated guard) instead of on every mount, never log out on a transient 429/5xx (only a definitive 401), and add a short rotation grace so two tabs refreshing concurrently don’t trip reuse-detection and nuke the whole token family. But the headline lesson is the cheap one: know which of your auth endpoints are actually brute-force surfaces, and don’t let a class-level decorator decide for you.