Companion code for the follow-up article. The repository now holds two Maven
projects sharing one docs/ tree:
jwt-authentication/ the hand-written filter application (unchanged, moved)
oauth2-resource-server/ a resource server, a Keycloak compose, and a stub
issuer whose JWK Set can be mutated on command
The stub exists because Keycloak will not rotate a signing key at a chosen
second, report how many times its JWKS endpoint was fetched, or drop a key from
the published set on request - and the caching and rotation measurements need
all three. The Keycloak run confirms the same code path against a real issuer.
Findings captured under docs/output/, all from real runs:
* The default validator stack does not check aud. A token minted for another
service in the same realm is accepted.
* Spring Security builds its JWKSource with refreshAheadCache(false) and
rateLimited(false), overriding two of Nimbus's protective defaults, and
enables Nimbus caching only when NO Spring cache was supplied - so
supplying one removes the five-minute expiry.
* A key retired from the JWK Set stops being accepted at t+300s with the
default cache, and never with a Spring cache that has no TTL.
* 25 tokens carrying an unknown kid produce 25 JWKS fetches at the issuer,
through permitAll() endpoints included.
* A hyphenated client id in an authorities-claim-expression parses as
subtraction; the SpelEvaluationException is swallowed and logged at TRACE.
* A clientScopes key in a Keycloak realm import replaces the built-in scopes
rather than adding to them.
New docs chapters 12-18. README covers both projects. Existing docs and scripts
updated for the new paths; no docs/output/ file from the first article moved, so
links in the published article still resolve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013f7f2XZXrQ6gW3RtZE187t
131 lines
5.4 KiB
Markdown
131 lines
5.4 KiB
Markdown
# 06 — SecurityContext and statelessness
|
|
|
|
[← HS256 vs RS256](05-hs256-vs-rs256.md) · [next: edge cases →](07-edge-cases.md)
|
|
|
|
## What "stateless" actually requires
|
|
|
|
Three separate settings, and setting only one of them is the usual mistake.
|
|
|
|
```java
|
|
.sessionManagement(session -> session
|
|
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
|
.securityContext(context -> context
|
|
.securityContextRepository(new NullSecurityContextRepository()))
|
|
.csrf(csrf -> csrf.disable())
|
|
```
|
|
|
|
`SessionCreationPolicy.STATELESS` stops **Spring Security** from creating or using a
|
|
session. It does not stop your application: any `request.getSession()`, any
|
|
`@SessionAttributes`, any Spring Session integration still creates one. And it does not
|
|
stop the `SecurityContextRepository` from being consulted.
|
|
|
|
`NullSecurityContextRepository` closes the second half. Without it the default is
|
|
`DelegatingSecurityContextRepository(RequestAttributeSecurityContextRepository,
|
|
HttpSessionSecurityContextRepository)` — so a `SecurityContext` you save goes into an
|
|
`HttpSession`, and a session cookie appears in a response you believed was stateless.
|
|
|
|
Verify rather than assume: the transcript prints `Set-Cookie` if one appears. In
|
|
[`curl-transcript-hs256.txt`](output/curl-transcript-hs256.txt), none does.
|
|
|
|
## `SecurityContextHolderFilter` and explicit save
|
|
|
|
Spring Security 6 replaced `SecurityContextPersistenceFilter` with
|
|
`SecurityContextHolderFilter`. The difference is one line of behaviour:
|
|
|
|
| | loads context | saves context |
|
|
|---|---|---|
|
|
| `SecurityContextPersistenceFilter` (legacy) | yes | **automatically**, at the end of the request |
|
|
| `SecurityContextHolderFilter` (6.0+ default) | yes | **no — you must call `saveContext`** |
|
|
|
|
Anything that authenticates a request must now say so explicitly:
|
|
|
|
```java
|
|
SecurityContext context = this.contextHolderStrategy.createEmptyContext();
|
|
context.setAuthentication(authentication);
|
|
this.contextHolderStrategy.setContext(context);
|
|
this.contextRepository.saveContext(context, request, response); // <-- easy to forget
|
|
```
|
|
|
|
For a genuinely stateless API `saveContext` on a `NullSecurityContextRepository` is a
|
|
no-op, so omitting it appears to work — until an `ERROR` dispatch, a `FORWARD`, or an
|
|
async re-dispatch clears the `ThreadLocal` and the principal vanishes on `/error`.
|
|
[`JwtAuthenticationFilter`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java)
|
|
uses `RequestAttributeSecurityContextRepository`, which survives a dispatch without ever
|
|
touching a session — the right middle ground.
|
|
|
|
## Always create the context, never mutate the shared one
|
|
|
|
```java
|
|
// wrong - mutates a context that may be shared
|
|
SecurityContextHolder.getContext().setAuthentication(auth);
|
|
|
|
// right
|
|
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
|
context.setAuthentication(auth);
|
|
SecurityContextHolder.setContext(context);
|
|
```
|
|
|
|
The first form has been discouraged since 5.7 and is a real race in multi-threaded
|
|
handling.
|
|
|
|
## Use the strategy, not the static methods
|
|
|
|
```java
|
|
private final SecurityContextHolderStrategy contextHolderStrategy =
|
|
SecurityContextHolder.getContextHolderStrategy();
|
|
```
|
|
|
|
`SecurityContextHolder`'s static methods delegate to whatever strategy is installed, but
|
|
capturing the strategy once is what the framework's own filters do, and it is the only
|
|
form that keeps working when the application swaps in a delegating strategy — the usual
|
|
reasons being observability, tenant propagation, or structured concurrency.
|
|
|
|
## The thread boundary
|
|
|
|
`SecurityContextHolder` is a `ThreadLocal`. It does not cross threads. `GET
|
|
/api/async-demo` proves it — from the [transcript](output/curl-transcript-hs256.txt),
|
|
step 20:
|
|
|
|
```json
|
|
{
|
|
"onRequestThread" : "root",
|
|
"onPlainExecutor" : "null (context did not cross the thread)",
|
|
"onDelegatingExecutor" : "root"
|
|
}
|
|
```
|
|
|
|
Same request, same instant, three answers. The middle one is what a `@Async` method, a
|
|
plain `CompletableFuture.supplyAsync`, or a raw executor sees.
|
|
|
|
Fixes, in order of scope:
|
|
|
|
```java
|
|
// one executor
|
|
new DelegatingSecurityContextExecutorService(Executors.newVirtualThreadPerTaskExecutor());
|
|
|
|
// one task
|
|
new DelegatingSecurityContextRunnable(task);
|
|
new DelegatingSecurityContextCallable<>(task);
|
|
|
|
// the whole application - context inherited by child threads
|
|
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
|
|
```
|
|
|
|
`MODE_INHERITABLETHREADLOCAL` is the tempting one and the wrong one for a servlet
|
|
container: threads are **pooled**, so "child" is whatever thread the pool happens to
|
|
spawn, and a context can be inherited by a task belonging to a different request. Wrap
|
|
executors instead.
|
|
|
|
For `@Async` specifically, Spring Security's
|
|
`DelegatingSecurityContextAsyncTaskExecutor` wraps the task executor; ankurm.com has a
|
|
[dedicated guide to context propagation](https://ankurm.com/spring-security-context-propagation-complete-guide/).
|
|
|
|
## Virtual threads
|
|
|
|
Boot 4.1 on JDK 25 makes `spring.threads.virtual.enabled=true` unremarkable. `ThreadLocal`
|
|
works on a virtual thread exactly as on a platform thread, so the `SecurityContext`
|
|
behaves identically. The one thing that changes: virtual threads are *not* pooled, so
|
|
the cross-request leak from a stale `ThreadLocal` is far less likely — which is a reason
|
|
to be *more* careful, not less, because the bug becomes rarer and harder to reproduce
|
|
rather than absent. Clear the context on the failure path regardless.
|