1
0
Files
spring-auth-demo/docs/06-securitycontext-and-statelessness.md
Ankur Mhatre 4dc45d5e00 Add OAuth2 resource server project: JWT validation, JWKS and key rotation
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
2026-08-23 11:00:56 +00:00

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.