1
0
Files
spring-security-demo/docs/07-servlet-filter-persistence.md
Ankur 9f950bffa9 Add every example from the post, plus edge cases, to the companion repo
Full companion repo for the ankurm.com post "Spring Security Context Propagation:
The Complete Guide" -- every code example the post discusses now has a corresponding
runnable, verified demo (JDK 25, Spring Security 7.1.1, Spring Boot 4.1.1 dependency
versions), not just the virtual-thread/structured-concurrency sections:

- Demo1PlainThreadLocal: InheritableThreadLocal across thread models (no Spring)
- Demo2AsyncVirtualThreads: @Async on a virtual-thread SimpleAsyncTaskExecutor
  (DelegatingSecurityContextExecutor vs ContextPropagatingTaskDecorator)
- Demo3StructuredConcurrency: StructuredTaskScope.fork() propagation
- Demo4ExecutorWrapping: DelegatingSecurityContextExecutorService/Executor/
  AsyncTaskExecutor on a classic pooled platform-thread executor -- the post's
  "Using @Async" / "Using ExecutorService" / "Using CompletableFuture" sections
- Demo5ReactiveContext: ReactiveSecurityContextHolder vs. ThreadLocal across a
  Reactor scheduler hop -- the post's WebFlux/getProfile() section
- Demo6ScheduledSystemIdentity: DelegatingSecurityContextTaskScheduler's actual
  per-call capture semantics (confirmed via bytecode before writing the demo) and
  the createSystemContext() pattern -- the post's scheduled-tasks section
- Demo7ServletFilterPersistence: SecurityContextHolderFilter (load-only) vs.
  SecurityContextPersistenceFilter (load+auto-save), against real filter instances
  and a real HttpSession -- the post's servlet-environment section
- SecurityContextPropagationContractTest: 10 JUnit tests pinning the above as
  assertions instead of printed lines, including a TestSecurityContextHolder-based
  test reproducing the post's own "Testing Security Context Propagation" section

Thirteen edge cases discovered along the way are indexed in docs/08 with links into
the chapter that reproduces each one -- a reused pool worker NOT leaking under the
Delegating* wrappers (unlike Demo1's InheritableThreadLocal), the common ForkJoinPool
trap, why there's no DelegatingSecurityContextStructuredTaskScope and never will be,
a real NullPointerException from Reactor's map() hit while writing the reactive test,
per-call (not per-construction) context capture in DelegatingSecurityContextTaskScheduler,
and the precise load-vs-save split between the two servlet filters, among others.

docs/01-08 are numbered, cross-linked chapters with prev/next navigation; README
indexes all demos, chapters, captured output, and the edge-case list. scripts/run-all.sh
regenerates every docs/output/*.txt and the test suite output from one command.
2026-08-24 16:49:30 +00:00

4.3 KiB

7. SecurityContextHolderFilter vs. SecurityContextPersistenceFilter

← Prev: Scheduled tasks | Next: Testing contract + edge-case index →

The post's "Security Context Propagation in Servlet Environment" section makes a specific, checkable claim: since Spring Security 6.0, SecurityContextHolderFilter replaced SecurityContextPersistenceFilter as the default, and the two behave differently in a way that matters -- the old filter auto-saved the context at the end of the request, the new one only loads. Demo7ServletFilterPersistence.java runs both real filter classes against a real HttpSession (via Spring Test's MockHttpServletRequest/MockHttpServletResponse, no servlet container) to confirm it directly rather than restate the reference docs. Full output in docs/output/demo7.txt.

Load vs. save, proven separately

Scenario A runs SecurityContextPersistenceFilter: the simulated controller sets an Authentication on SecurityContextHolder mid-chain, and once the filter's doFilter returns, the session already contains it:

A) SecurityContextPersistenceFilter, context set mid-chain, auto-saved to session after chain returns: true

Scenario B is the identical setup against SecurityContextHolderFilter, the Security 6+ default:

B) SecurityContextHolderFilter, context set mid-chain, auto-saved to session after chain returns: false

Nothing was written to the session. This is requireExplicitSave's default behavior made concrete: setting SecurityContextHolder.setContext(...) inside request processing does not, by itself, persist anything past the current request under the Security-6-default filter.

The fix, proven too

Scenario C repeats B but adds one line inside the simulated chain -- repository.saveContext(ctx, request, response) -- the exact workaround the post recommends for custom pre-authentication filters that set the context directly:

C) SecurityContextHolderFilter + explicit repository.saveContext(...) inside the chain: true

Edge case: "only loads, never saves" describes the save side, not the load side

It's easy to over-read "only loads, never saves" as "does almost nothing." Scenario D seeds a session with a context (simulating what an earlier request's explicit saveContext(...) would have left behind) and confirms SecurityContextHolderFilter still loads it correctly on a subsequent request through the same session:

D) SecurityContextHolderFilter, context already saved in an existing session, next request: authenticated as dave

The filter's whole job on the read side is unchanged; the only thing Security 6 removed is the automatic write at the end.

Edge case: no session, no prior save -- not an error

Scenario E runs a completely fresh request through SecurityContextHolderFilter with no existing session and nothing set anywhere:

EDGE) brand-new request, no prior session, nothing set: NO AUTHENTICATION (empty context, not an error)

SecurityContextHolder.getContext() never returns null -- Spring Security's SecurityContextHolderStrategy always hands back an empty SecurityContext object whose getAuthentication() is null, rather than a null context itself. Code that checks context == null to detect "nobody's authenticated" is checking the wrong thing; check context.getAuthentication() == null instead.

Why this matters more than it looks

requireExplicitSave(true) (shown in the post's WebSecurityConfig) is not something you turn on -- it has been the default since Security 6.0, and OpenRewrite ships a migration recipe specifically to remove the explicit call as dead weight when upgrading to 6.0. The behavior it names, though, is exactly what scenarios A/B/C above measure: the old auto-save wrote to the session on every request regardless of whether the context had actually changed, which was wasteful and made intent ambiguous; the new default only writes when something explicitly asks it to. The framework's own authentication filters (form login, basic auth, OAuth2 login) already call saveContext(...) after a successful login, so this rarely bites application code -- it becomes a real bug only in code that calls SecurityContextHolder.setContext(...) directly, outside that flow, exactly the custom pre-authentication-filter case the post calls out.