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 incommon.security.SecurityConfig. - Default: on (M10-017 — security features activate by default).
- Counts failed attempts only — a response of
401or403(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, andPOST /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:
| Bucket | Key | Stops |
|---|---|---|
| Per-IP | client IP | one source walking every account (spraying) |
| Per-(IP, username) | client IP + SHA-256 of the username | guessing 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
400is a malformed request rather than a wrong guess, and a5xxis our fault — letting either consume budget turns a client bug or an outage into a lockout; - a
429is 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 var | Property | Default | Meaning |
|---|---|---|---|
SECURITY_RATE_LIMIT_ENABLED | security.rate-limit.enabled | true | Master switch. When false, the authentication surface behaves exactly as it did before this feature existed. |
SECURITY_RATE_LIMIT_WINDOW | security.rate-limit.window | PT1M | Window length (ISO-8601 duration). |
SECURITY_RATE_LIMIT_IP_MAX_ATTEMPTS | security.rate-limit.ip-max-attempts | 120 | Failed attempts per window from one IP across all limited paths. |
SECURITY_RATE_LIMIT_IP_USERNAME_MAX_ATTEMPTS | security.rate-limit.ip-username-max-attempts | 5 | Failed attempts per window from one IP against one username. |
SECURITY_RATE_LIMIT_MAX_TRACKED_KEYS | security.rate-limit.max-tracked-keys | 100000 | Hard 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:
FORWARD_HEADERS_STRATEGY=framework(server.forward-headers-strategy) stays set, so Spring'sForwardedHeaderFilterapplies the forwarded headers before any application filter runs; and- the app is not reachable except through the trusted proxy. A directly addressable pod lets a
client set its own
X-Forwarded-Forand 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.
Related
- Password reset issues a one-time link — why its refusal is a
401rather than the house400: this filter counts only401and403, so a400there would have left token guessing free. - Access control — the engine that owns local login and the security log.