# spring-security-demo Companion repo for [Spring Security Context Propagation: The Complete Guide](https://ankurm.com/spring-security-context-propagation-complete-guide/) on ankurm.com. Eight small, dependency-light programs and one JUnit test suite that answer one question each: does a Spring Security `SecurityContext` survive a specific thread, scheduler, or request hand-off? Every scenario sets an `Authentication` somewhere and checks whether the other side of the hand-off can see it -- against real executors, a real Reactor pipeline, a real `TaskScheduler`, and real servlet filter classes (via Spring Test's mock request/response, no running server needed). Every example the post shows -- `@Async`, `ExecutorService`, `CompletableFuture`, virtual threads, `StructuredTaskScope`, `ReactiveSecurityContextHolder`/WebFlux, `DelegatingSecurityContextTaskScheduler` and scheduled tasks, and the `SecurityContextHolderFilter`/`SecurityContextPersistenceFilter` servlet distinction -- has a runnable demo here, plus edge cases the post doesn't have room for. See the [edge-case index](docs/08-testing-contract.md#edge-case-index) for the full list with links. ## Verified versions | Component | Version | |---|---| | JDK | 25 (Temurin 25.0.4.1+1), LTS, GA 2025-09-16 | | Spring Boot (reference target) | 4.1.1 | | Spring Framework | 7.0.9 | | Spring Security | 7.1.1 | | Spring Security Test | 7.1.1 | | `io.micrometer:context-propagation` | 1.2.1 (as managed by Boot 4.1.1's BOM) | | Reactor Core / Reactor Test | 3.8.7 (as managed by Boot 4.1.1's `reactor-bom` 2025.0.7) | | `jakarta.servlet-api` | 6.1.0 | | JUnit Jupiter | 6.0.3 | | AssertJ | 3.27.7 | `StructuredTaskScope` is a **preview API** on JDK 25 (JEP 505, fifth preview) and remains preview through JDK 26 (JEP 525, sixth preview) -- every build/run/test command below needs `--enable-preview`. ## Quickstart ```bash mvn dependency:build-classpath -Dmdep.outputFile=cp.txt javac --release 25 --enable-preview -cp "$(cat cp.txt)" -d target/classes $(find src/main -name '*.java') java --enable-preview -cp "target/classes:$(cat cp.txt)" com.ankurm.vt.Demo1PlainThreadLocal ``` Or just run everything -- all seven demos plus the test suite -- and regenerate the captured output: `scripts/run-all.sh`. To run only the JUnit contract tests: `mvn test` (the `--enable-preview` flag is already wired into `pom.xml`'s surefire `argLine`, no extra flags needed). ## What each demo shows | Demo | Question | Chapter | |---|---|---| | `Demo1PlainThreadLocal` | Does `InheritableThreadLocal` behave differently for a pooled platform thread vs. a fresh virtual thread? (No Spring.) | [docs/01](docs/01-inheritable-threadlocal.md) | | `Demo2AsyncVirtualThreads` | Does the Boot-4.1-style virtual-thread `@Async` executor propagate `SecurityContext`, and what four fixes change? | [docs/02](docs/02-async-virtual-threads.md) | | `Demo3StructuredConcurrency` | Does a `StructuredTaskScope.fork()` subtask see the parent's `SecurityContext`? | [docs/03](docs/03-structured-concurrency.md) | | `Demo4ExecutorWrapping` | Do `DelegatingSecurityContextExecutorService`/`Executor`/`AsyncTaskExecutor` propagate context on a classic *pooled platform-thread* executor, and does a reused worker leak between tasks the way `InheritableThreadLocal` did in Demo 1? | [docs/04](docs/04-executor-wrapping.md) | | `Demo5ReactiveContext` | Does `ReactiveSecurityContextHolder` survive a scheduler hop that kills plain `ThreadLocal`-based `SecurityContextHolder`? | [docs/05](docs/05-reactive-context.md) | | `Demo6ScheduledSystemIdentity` | What does `DelegatingSecurityContextTaskScheduler` actually capture, and when -- and how does the post's `createSystemContext()` pattern fix the fact that there's no real caller to propagate from? | [docs/06](docs/06-scheduled-tasks.md) | | `Demo7ServletFilterPersistence` | Does `SecurityContextHolderFilter` really never save, while `SecurityContextPersistenceFilter` does -- proven against real filter instances and a real `HttpSession`? | [docs/07](docs/07-servlet-filter-persistence.md) | | `SecurityContextPropagationContractTest` (JUnit, `src/test`) | Same ten claims above, pinned as assertions instead of printed lines; includes a `TestSecurityContextHolder`-based test reproducing the post's own "Testing Security Context Propagation" section | [docs/08](docs/08-testing-contract.md) | ## Endpoints / entry points There's no web server in this repo (see the top of this file), so "entry points" means: every class above has a runnable `main()`, and the whole suite runs end to end via `scripts/run-all.sh`. `Demo5ReactiveContext` reproduces the post's `/profile` (`ReactiveSecurityContextHolder`) behavior as a plain `Mono` chain rather than a bound HTTP route, and `Demo7ServletFilterPersistence` reproduces the filter chain's request-scoped behavior against `MockHttpServletRequest`/`MockHttpServletResponse` rather than a bound servlet container -- both keep the "no web server, no HTTP" property the original three demos established, so the whole repo still runs in well under a second with zero open ports. ## Captured output Every number and log line in the blog post traces back to one of these, produced by `scripts/run-all.sh`, not retyped: - [docs/output/demo1.txt](docs/output/demo1.txt) - [docs/output/demo2.txt](docs/output/demo2.txt) - [docs/output/demo3.txt](docs/output/demo3.txt) - [docs/output/demo4.txt](docs/output/demo4.txt) - [docs/output/demo5.txt](docs/output/demo5.txt) - [docs/output/demo6.txt](docs/output/demo6.txt) - [docs/output/demo7.txt](docs/output/demo7.txt) - [docs/output/tests.txt](docs/output/tests.txt) -- `mvn test` surefire summary for the ten contract tests ## Doc chapters Numbered, cross-linked, each with prev/next navigation at the top: 1. [InheritableThreadLocal across thread models](docs/01-inheritable-threadlocal.md) 2. [@Async, DelegatingSecurityContextExecutor, and virtual threads](docs/02-async-virtual-threads.md) 3. [StructuredTaskScope and SecurityContext](docs/03-structured-concurrency.md) 4. [Executor, ExecutorService, and AsyncTaskExecutor wrapping](docs/04-executor-wrapping.md) 5. [ReactiveSecurityContextHolder and Reactor Context](docs/05-reactive-context.md) 6. [DelegatingSecurityContextTaskScheduler and the synthetic system identity](docs/06-scheduled-tasks.md) 7. [SecurityContextHolderFilter vs. SecurityContextPersistenceFilter](docs/07-servlet-filter-persistence.md) 8. [Testing contract + edge-case index](docs/08-testing-contract.md) ## Edge cases Thirteen reproducible edge cases were found building this repository -- pooled-worker leaks the `Delegating*` classes don't have, the common `ForkJoinPool` trap, why `MODE_INHERITABLETHREADLOCAL` is a JVM-wide instrument, why there's no `DelegatingSecurityContextStructuredTaskScope` and never will be, a real `NullPointerException` hit writing the reactive test, `ReactiveSecurityContextHolder` completing empty rather than erroring, per-call (not per-construction) context capture in `DelegatingSecurityContextTaskScheduler`, why a synthetic `SYSTEM` principal isn't "anonymous", and the precise load-vs-save split between the two servlet filters. Full list, each with the chapter that reproduces it: [docs/08 § Edge-case index](docs/08-testing-contract.md#edge-case-index). ## The one-line summary of all eight chapters `SecurityContextHolder` is a `ThreadLocal`. Nothing about virtual threads, structured concurrency, reactive streams, schedulers, or servlet filters changes that fact -- what changes between them is *how* (or whether) anything carries that `ThreadLocal`'s value across the boundary each one introduces. Virtual threads are never pooled, so `MODE_INHERITABLETHREADLOCAL`'s old danger (stale context on a reused pool worker) doesn't apply to them, but it's still a JVM-wide setting. The `Delegating*` wrapper family solves the same pooled-worker problem by a completely different mechanism -- explicit push/pop per task, never thread inheritance -- which is why it has worked, unchanged, since long before virtual threads existed. Reactive code doesn't have a `ThreadLocal`-compatible thread to begin with, so `ReactiveSecurityContextHolder` uses Reactor's own `Context` instead. Scheduled tasks have no caller at all, so the fix isn't propagation, it's minting an identity. And the servlet filter that used to auto-save the context for you was replaced by one that only loads -- a change worth knowing about before it's the reason a custom filter's write silently doesn't survive to the next request.