Runnable companion for https://ankurm.com/spring-security-7-1-jwt-authentication-guide/ - login -> token issue -> OncePerRequestFilter -> SecurityContext, end to end - HS256 and RS256 variants (RS256 publishes a real JWKS endpoint) - the same API secured by the built-in oauth2ResourceServer().jwt(), for comparison - 11 documentation chapters under docs/, interlinked with the code - docs/output/ is real captured output, regenerated by scripts/run-all.sh - 13 passing tests pinning the 401-vs-403 contract and the CSRF failure Verified against Spring Boot 4.1.1, Spring Security 7.1.1, JDK 25.0.4.1.
67 lines
5.0 KiB
Markdown
67 lines
5.0 KiB
Markdown
# 10 — Production checklist
|
|
|
|
[← manual vs resource server](09-manual-filter-vs-resource-server.md) · [next: Spring Security 7 changes →](11-spring-security-7-changes.md)
|
|
|
|
Run this list before shipping. Each item links to the section that explains it.
|
|
|
|
## Keys and algorithms
|
|
|
|
- [ ] Signing key comes from a secret manager or KMS, **never** from `application.yaml`, and never from an environment variable baked into an image. The values in this repository are public demo values.
|
|
- [ ] HMAC secret is ≥ 32 bytes of **random** data (`openssl rand -base64 48`), not a passphrase that happens to be long enough. [→ 05](05-hs256-vs-rs256.md)
|
|
- [ ] The algorithm is pinned on the decoder (`.macAlgorithm(..)` / `.signatureAlgorithm(..)`), not left to the token's `alg` header. [→ 05](05-hs256-vs-rs256.md)
|
|
- [ ] If more than one service verifies tokens, the algorithm is asymmetric (RS256 / ES256). With HS256 every verifier can mint admin tokens. [→ 05](05-hs256-vs-rs256.md)
|
|
- [ ] Every key has a `kid`, and a rotation procedure exists and has been rehearsed. [→ 05](05-hs256-vs-rs256.md)
|
|
- [ ] A published JWKS contains `n` and `e` only — grep it for `"d"`, `"p"`, `"q"` before exposing it.
|
|
|
|
## Claims and validation
|
|
|
|
- [ ] `aud` is validated. It is **not** validated by default. [→ 07 §1](07-edge-cases.md#audience)
|
|
- [ ] `iss` is validated (`JwtValidators.createDefaultWithIssuer`).
|
|
- [ ] Access and refresh tokens are distinguishable, and the distinction is enforced on every request. [→ 07 §2](07-edge-cases.md#refresh-token-as-access-token)
|
|
- [ ] Clock skew is a deliberate number, not an accepted default of 60s. [→ 07 §3](07-edge-cases.md)
|
|
- [ ] No PII in claims. A JWT is signed, not encrypted. [→ 07 §7](07-edge-cases.md)
|
|
- [ ] Token size measured against your proxy's header limit, with the most privileged user's token. [→ 07 §8](07-edge-cases.md)
|
|
|
|
## Lifetimes and revocation
|
|
|
|
- [ ] Access-token TTL is minutes, not hours or days.
|
|
- [ ] Refresh tokens rotate on use, and a replay invalidates the family. [→ 07 §5](07-edge-cases.md)
|
|
- [ ] Every token carries a `jti`, and a denylist exists for logout, password change, and compromise. [→ 07 §4](07-edge-cases.md#logout-and-revocation)
|
|
- [ ] The denylist is shared across instances (Redis, not a `ConcurrentHashMap`) and entries expire.
|
|
- [ ] "Log out everywhere" is possible — usually a per-user `tokensValidAfter` timestamp compared against `iat`.
|
|
|
|
## Chain configuration
|
|
|
|
- [ ] CSRF decision is deliberate and matches where the token lives: disabled **only** if no credential is ambient. [→ 04](04-csrf-permitall-403.md)
|
|
- [ ] `SessionCreationPolicy.STATELESS` **and** `NullSecurityContextRepository`. Verify no `Set-Cookie` appears in a response. [→ 06](06-securitycontext-and-statelessness.md)
|
|
- [ ] `formLogin`, `httpBasic` and `logout` are explicitly disabled if unused — otherwise a browser-shaped fallback exists on your API.
|
|
- [ ] Custom filter is `addFilterBefore(..., UsernamePasswordAuthenticationFilter.class)`, extends `OncePerRequestFilter`, and is **not** also registered as a servlet filter. [→ 02](02-filter-chain-and-ordering.md)
|
|
- [ ] `anyRequest()` is the last rule. [→ 07 §12](07-edge-cases.md)
|
|
- [ ] The filter clears the `SecurityContext` on every failure path. [→ 02](02-filter-chain-and-ordering.md)
|
|
- [ ] `AuthenticationEntryPoint` and `AccessDeniedHandler` are both configured, and login failures have a `@RestControllerAdvice`. [→ 03](03-401-vs-403.md)
|
|
- [ ] The filter-chain diagnostic endpoint (`/api/public/filters` here) is **removed**.
|
|
|
|
## Responses
|
|
|
|
- [ ] Login failures are indistinguishable across bad-password, unknown-user, locked and disabled. [→ 03](03-401-vs-403.md)
|
|
- [ ] `error_description` does not leak expiry timestamps or internal URLs in production. [→ 07 §16](07-edge-cases.md)
|
|
- [ ] 401 carries `WWW-Authenticate` with a real RFC 6750 error code, not a bare realm. [→ 07 §15](07-edge-cases.md)
|
|
- [ ] Rate limiting on `/login` and `/refresh`. Nothing in Spring Security does this for you, and an unthrottled login endpoint with bcrypt is also a CPU denial-of-service.
|
|
|
|
## Transport and operations
|
|
|
|
- [ ] HTTPS enforced; HSTS on.
|
|
- [ ] Tokens never in URLs, and `allowUriQueryParameter` is off. [→ 07 §13](07-edge-cases.md)
|
|
- [ ] Access logs do not record the `Authorization` header.
|
|
- [ ] Authentication failures are logged with enough context to alert on, and a spike in `invalid_token` is alertable.
|
|
- [ ] `@Async`/executor boundaries wrap the `SecurityContext`. [→ 06](06-securitycontext-and-statelessness.md)
|
|
- [ ] Dependency scanning covers `nimbus-jose-jwt` — it is where JOSE CVEs land.
|
|
|
|
## Before you build any of this
|
|
|
|
Ask whether you should. If you need sessions and have one server-rendered application,
|
|
a session cookie is simpler, revocable by design, and has no key management. If you need
|
|
federated identity, an authorization server (Keycloak, Auth0, Okta, Spring Authorization
|
|
Server) already implements every item on this list. A hand-rolled JWT layer is the right
|
|
answer for a stateless API you own end to end — and a lot of work everywhere else.
|