Documents the double-submit cookie CSRF pattern, sequential token-bucket rate limiter with refund mechanic, and session revocation on password change/reset — all implemented as part of issue #524. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4.4 KiB
ADR-022 — CSRF Protection, Session Revocation, and Login Rate Limiting
Date: 2026-05-18 Status: Accepted Issue: #524
Context
ADR-020 established stateful authentication via Spring Session JDBC. Three follow-on security concerns were left open:
-
CSRF. State-changing API calls from the SvelteKit frontend use session cookies. Without CSRF protection an attacker can forge cross-origin requests that carry the victim's session cookie.
-
Session revocation. A user who changes or resets their password may still have other active sessions (other browsers, shared devices). Those sessions should be invalidated so the credential change takes full effect immediately.
-
Login rate limiting. The login endpoint accepts arbitrary email/password pairs. Without throttling it is vulnerable to brute-force and credential- stuffing attacks.
Decision
1. CSRF — double-submit cookie pattern
SecurityConfig enables CookieCsrfTokenRepository.withHttpOnlyFalse():
- The backend sets an
XSRF-TOKENcookie (readable by JavaScript) on every response. - All state-changing requests (
POST,PUT,PATCH,DELETE) must include anX-XSRF-TOKENrequest header whose value matches the cookie. CsrfTokenRequestAttributeHandleris used (non-XOR mode) — correct for SPAs where token deferred loading would otherwise corrupt values.- SvelteKit's
handleFetchhook injects the header and mirrors the cookie for every mutating API call. - CSRF validation failures return HTTP 403 with JSON body
{"code": "CSRF_TOKEN_MISSING"}via a customAccessDeniedHandler.
Login (POST /api/auth/login), forgot-password, and reset-password are
not CSRF-exempt — the XSRF-TOKEN cookie is set on the first GET to the
login page, so the double-submit requirement is satisfiable from the browser.
2. Session revocation
AuthService gains two methods backed by JdbcIndexedSessionRepository:
revokeOtherSessions(currentSessionId, principal)— deletes all sessions for a principal except the caller's current session. Called on password change so the user stays logged in on the current device.revokeAllSessions(principal)— deletes every session for a principal. Called on password reset (unauthenticated flow) so no prior sessions survive.
Both methods are no-ops when sessionRepository is null (unit-test
contexts that do not load Spring Session).
3. Login rate limiting — in-memory token bucket
LoginRateLimiter (Bucket4j + Caffeine) enforces two independent limits:
| Bucket | Limit | Window | Key |
|---|---|---|---|
| Per IP + email | 10 attempts | 15 min | ip:email |
| Per IP (all emails) | 20 attempts | 15 min | ip |
On each login attempt both buckets are checked sequentially:
- Consume from the
ip:emailbucket first. - If the IP-level bucket is exhausted, refund the
ip:emailtoken.
The refund prevents IP-level blocking from silently consuming per-email quota:
without it, 20 blocked attempts for target@example.com from a single IP
(caused by another email exhausting the IP bucket) would drain all 10 of
target@'s tokens.
On a successful login both buckets are invalidated for that (ip, email) pair
so a legitimately authenticated user regains the full window immediately.
Rate-limit violations are audited as LOGIN_RATE_LIMITED events.
The cache is node-local (in-memory). In a multi-replica deployment the effective rate limit is multiplied by the replica count. This is acceptable for the current single-VPS production setup and is noted with a comment in the source.
Consequences
- CSRF: All SvelteKit API calls must supply
X-XSRF-TOKEN. Barecurlcalls or non-browser clients must obtain and pass the token manually. Integration tests use.with(csrf())fromspring-security-test. - Session revocation: Requires
JdbcIndexedSessionRepositoryto be wired (Spring Session JDBC dependency). Unit tests injectnulland verify the no-op path. - Rate limiting: False positives are possible if many users share a NAT/VPN IP. The per-IP limit (20) is intentionally loose to reduce collateral blocking; the per-IP+email limit (10) is the primary defence.
ObjectMapperin the CSRFAccessDeniedHandleruses a static instance because@WebMvcTestslices excludeJacksonAutoConfiguration. The response only serialises a fixed String key ("code") so naming strategy and custom modules are irrelevant.