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.
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`](../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.
|