Skip to main content

Authentication rate limiting (M33-002)

Throttles the authentication surface per client IP and per (client IP, username), so that guessing credentials costs an attacker something. Before this, there was no rate limiting anywhere in the platform.

  • Owned by: common.security.ratelimit (AuthRateLimitFilter, RateLimitProperties), wired into both filter chains in common.security.SecurityConfig.
  • Default: on (M10-017 — security features activate by default).
  • Counts failed attempts only — a response of 401 or 403 (M33-002A). A successful login costs no budget; see What counts as an attempt.
  • Applies to: POST /api/v1/auth/login, POST /api/v1/auth/refresh, and POST /api/v1/auth/password-reset/redeem (SEC-010).
  • Deliberately not applied to: /api/v1/auth/verify, which the frontend calls on every page load.

Why two buckets, when accounts already lock

LocalAuthService locks an account after 5 failures in 15 minutes. That control alone hands an attacker two wins:

  • Username spraying is unthrottled. One attempt per account never trips a lock, so an attacker can try one popular password against every clinician in the register and be refused by nothing.
  • The lockout is itself a denial of service. Five deliberately wrong passwords take a named clinician offline for fifteen minutes, and a script can walk the whole staff list.

Only throttling the source fixes the second one, because the target is the victim. So:

BucketKeyStops
Per-IPclient IPone source walking every account (spraying)
Per-(IP, username)client IP + SHA-256 of the usernameguessing at a single door

The per-(IP, username) limit is the tighter of the two, so an attacker aimed at one account exhausts it long before the shared per-IP budget that a colleague behind the same NAT also draws from.

The redeem path is in the per-IP list only. The per-(IP, username) bucket parses a JSON field literally named username, and a {token, newPassword} body does not carry one.

What counts as an attempt

Only a failed authentication consumes budget — a downstream response of 401 or 403 (AuthRateLimitFilter.isAuthenticationFailure). The filter therefore checks the budget before the chain runs and counts after it, rather than counting on the way in.

That is a correction, not the original design (M33-002A). An earlier revision counted every attempt, and CI proved it wrong: AbstractionContainerBaseTest opens a real super_admin session for every integration test class from one address, so the sixth correct login was refused with a 429. The same would reach real users — several tabs, a mobile client re-authenticating, a service account, a busy morning. This control exists to stop guessing, and guessing is made of failures, so failures are what it counts. Do not "simplify" this back to counting all attempts.

The set is deliberately narrow in the other direction too:

  • a 400 is a malformed request rather than a wrong guess, and a 5xx is our fault — letting either consume budget turns a client bug or an outage into a lockout;
  • a 429 is written by this filter and never reaches the counting step, so a blocked window can never renew itself indefinitely.

Merely asking whether a key is exhausted never creates a counter entry (getIfPresent, not getUnchecked). A key with no recorded failures must not occupy a cache slot, or an attacker could evict real counters by spraying keys that never fail.

Behaviour

A refused request gets 429 Too Many Requests with a Retry-After header (seconds) and the platform's standard error body — errorCode (the integer 429, not a string), apiPath, errorMessage, errorTime — so the frontend can show "too many attempts, retry in N" rather than the generic "invalid credentials" (FE-362). The body is written straight to the response rather than thrown, because these paths are permitAll and an exception would surface as a generic 500 through the security chain instead of the specific status FE-362 renders.

Each refusal also appends a RATE_LIMITED security event carrying the client IP, the path, and a reason category (AUTH_RATE_LIMIT_IP or AUTH_RATE_LIMIT_IP_USERNAME) — never the submitted username, because a mistyped password is frequently the password.

Counters are plain fixed windows, not sliding windows: a window opens on the first counted failure for a key and every later failure in that window increments the same counter. Up to twice a limit can therefore land across a window boundary. That is accepted deliberately — the control exists to make spraying expensive, not to be exact.

Configuration

Env varPropertyDefaultMeaning
SECURITY_RATE_LIMIT_ENABLEDsecurity.rate-limit.enabledtrueMaster switch. When false, the authentication surface behaves exactly as it did before this feature existed.
SECURITY_RATE_LIMIT_WINDOWsecurity.rate-limit.windowPT1MWindow length (ISO-8601 duration).
SECURITY_RATE_LIMIT_IP_MAX_ATTEMPTSsecurity.rate-limit.ip-max-attempts120Failed attempts per window from one IP across all limited paths.
SECURITY_RATE_LIMIT_IP_USERNAME_MAX_ATTEMPTSsecurity.rate-limit.ip-username-max-attempts5Failed attempts per window from one IP against one username.
SECURITY_RATE_LIMIT_MAX_TRACKED_KEYSsecurity.rate-limit.max-tracked-keys100000Hard ceiling on counter entries held per bucket.

The code defaults in RateLimitProperties and the ${…} defaults in both core and app application.yml agree; neither file overrides the other.

Why the per-IP number is high. It is tuned for a NATed site, not for an attacker: a hospital reaches the platform through one egress address, so this bucket is shared by everyone behind it. The fine-grained work is done by ip-username-max-attempts, which is 24× tighter and is what actually stops guessing at one account.

security.rate-limit.paths and security.rate-limit.username-paths are also bindable if the auth routes ever move; the second must stay a subset of the first. Prefer changing the defaults in RateLimitProperties over setting these in YAML — Spring list binding replaces rather than appends, so a YAML list written to add one path silently drops the others from the limiter. security.rate-limit.max-buffered-body-bytes (default 4096) caps the login body read described below.

:::warning Both path lists are matched by exact string RateLimitProperties.paths and SecurityConfig.AUTH_PUBLIC_PATHS are compared with List.contains — no pattern matching, no normalisation beyond stripping the context path. A path that is public in the second list and misspelled or missing in the first is anonymous and unthrottled, and it fails open: everything works and nothing warns. Keeping the whole anonymous surface under /api/v1/auth/** is what makes the two lists reviewable side by side — see SEC-010, whose redeem endpoint is the platform's first unauthenticated write and therefore depends on this agreement holding. :::

Two deployment facts this control depends on

The client IP comes from request.getRemoteAddr(). X-Forwarded-For is never parsed by this filter — it is a header the caller writes, and one spoofed value would defeat the control entirely. Behind the ingress the value is still correct only because:

  1. FORWARD_HEADERS_STRATEGY=framework (server.forward-headers-strategy) stays set, so Spring's ForwardedHeaderFilter applies the forwarded headers before any application filter runs; and
  2. the app is not reachable except through the trusted proxy. A directly addressable pod lets a client set its own X-Forwarded-For and become any IP it likes.

If either changes, this control silently stops working. Nothing in the application can detect that.

Memory safety — and the per-JVM limitation this brings with it

Both counter keys are attacker-controlled, so the store is a bounded Guava cache — expireAfterWrite on the window plus a maximumSize — rather than a map. An unbounded map keyed on attacker input would be a memory-exhaustion denial of service in its own right.

:::warning The counters are in memory, per JVM There is no shared store. Each replica counts on its own, so running N pods multiplies the effective limit by N — with the defaults, three pods behind a round-robin ingress admit roughly 360 failures a minute from one address, not 120. Counters also reset on every restart, rollout and scale-in, so a deploy hands an attacker a fresh budget.

Neither the application nor the configuration can detect this; it is a property of the deployment. Size the limits against the replica count you actually run, and treat a shared store (Redis or equivalent) as the prerequisite for tightening them meaningfully. :::

The path where every request counts

/api/v1/auth/password-reset/request (SEC-030) is in security.rate-limit.always-counted-paths, so every request against it consumes budget, not only the failed ones.

:::danger The two designs cancel out without this The filter counts only 401 and 403. That endpoint answers 204 whatever happens — unknown username, disabled account, no email on file — because anything else would make it an account-enumeration oracle open to the internet.

Put together, it consumed no budget ever: unlimited reset mail to any address on file and unlimited token churn, with nothing failing and nothing logged as refused. Two individually correct decisions, cancelling into an open unthrottled write. :::

Counting successes is safe here and nowhere else so far: nobody legitimately asks for a reset link twice a second. The same rule on /login is precisely what locked out a NATed hospital and is why this filter counts failures by default. Before adding a path to this list, ask what legitimate traffic looks like at peak — if the answer is "a burst at shift change", it does not belong here.

The path is also in username-paths, so the tighter per-(IP, username) bucket applies: five requests a minute for one account from one address.

Reading the login body without breaking login

The per-(IP, username) bucket needs the username, which arrives in the JSON request body, and reading a servlet body consumes it. On the login path only, the filter reads the body once (capped at 4 KB) and passes the rest of the chain a wrapper that replays exactly those bytes. Bodies that are not JSON, are empty, are larger than the cap, or arrive without a Content-Length are never buffered — those requests are counted by the per-IP bucket alone. The refresh and password-reset-redeem bodies, which carry a token and no username, are never read at all.

  • Password reset issues a one-time link — why its refusal is a 401 rather than the house 400: this filter counts only 401 and 403, so a 400 there would have left token guessing free.
  • Access control — the engine that owns local login and the security log.