Skip to content

Propagate deferred SecurityContext supplier#19427

Open
oldium wants to merge 2 commits into
spring-projects:mainfrom
oldium:fix/oj-gh18059-deferred-context
Open

Propagate deferred SecurityContext supplier#19427
oldium wants to merge 2 commits into
spring-projects:mainfrom
oldium:fix/oj-gh18059-deferred-context

Conversation

@oldium

@oldium oldium commented Jul 9, 2026

Copy link
Copy Markdown

Fixes a StackOverflowError that occurs when Micrometer Context Propagation captures or clears thread-locals while a deferred SecurityContext is set. Closes gh-18059.

Problem

SecurityContextHolderThreadLocalAccessor.getValue() called SecurityContextHolder.getContext(), which materializes the deferred SecurityContext. Micrometer invokes getValue() during snapshot capture and also when clearing thread-locals for a scope (DefaultContextSnapshot.clearThreadLocal saves the previous value before clearing), so any Mono.subscribe with automatic context propagation enabled can materialize the context — even when no SecurityContext key is present in the Reactor Context. When materialization itself subscribes to a Mono — e.g. RedisSessionRepository.findById via Lettuce connecting to Redis — propagation re-enters the accessor, SupplierDeferredSecurityContext.init() re-invokes the supplier (it caches only after the supplier returns), and the cycle recurses until StackOverflowError.

Solution

Propagate the deferred Supplier<SecurityContext> itself instead of the materialized context:

  • New SecurityContextHolderStrategy.peekDeferredContext() returns the stored Supplier without invoking it and without auto-creating or storing a context, so snapshot capture has no side effects. The default implementation materializes the current context eagerly (captured by value), preserving the previous propagation behavior for custom strategies that do not override it.
  • SecurityContextHolderThreadLocalAccessor now implements ThreadLocalAccessor<Supplier<SecurityContext>>: getValue() uses peekDeferredContext() and setValue(...) uses setDeferredContext(...), so the supplier is invoked lazily on the consuming thread.
  • getValue() keeps the pre-existing behavior of treating an empty SecurityContext as absent, applied only when the context is already materialized (marked internally via a package-private ConstantSupplier). An uninvoked deferred Supplier is propagated uninspected — inspecting it would reintroduce the recursion.
  • setDeferredContext(...) no longer re-wraps an already-validated Supplier, so micrometer's save/restore cycle does not accumulate wrappers.

Commits

The first commit adds tests that reproduce the issue against the previous implementation — they fail without the fix (recursion is bounded in the tests so a regression fails assertions instead of crashing the JVM). The second commit contains the fix. Squash if the intermediate failing-tests commit is not desired.

Notes for review

  • The value stored in snapshots / Reactor Context under the org.springframework.security.core.context.SecurityContext key changes type from SecurityContext to Supplier<SecurityContext>. This is inherent to deferring materialization. The key and the SecurityContextHolder API are unchanged, and micrometer invokes the accessor through the erased ThreadLocalAccessor interface. The reactive accessor uses a different key (SecurityContext.class) and is unaffected.
  • Residual behavioral delta: a deferred supplier that would yield an empty context is propagated (as an uninvoked supplier) where previously null was propagated after materializing it; the consuming thread observes an empty context either way.
  • @since 7.1.1 matches the current main version; happy to adjust if this should target a different release.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MydkAGSDAf44MJnYJJDm5i

oldium and others added 2 commits July 9, 2026 21:07
Micrometer Context Propagation invokes ThreadLocalAccessor.getValue()
when capturing a snapshot and when clearing thread-locals for a scope.
SecurityContextHolderThreadLocalAccessor materializes the deferred
SecurityContext in getValue(), so snapshot capture triggers session
loading; when that load itself subscribes to a Mono (e.g. Lettuce
connecting to Redis), propagation re-enters the accessor and recurses
until StackOverflowError.

Issue spring-projectsgh-18059

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Oldřich Jedlička <oldium.pro@gmail.com>
SecurityContextHolderThreadLocalAccessor now propagates the deferred
Supplier<SecurityContext> instead of materializing the SecurityContext
during Micrometer context snapshot capture. Materializing could load
the session from a store whose client subscribes to a Mono, which
re-entered the accessor through context propagation and recursed until
StackOverflowError.

The new SecurityContextHolderStrategy.peekDeferredContext() returns
the stored Supplier without invoking it and without auto-creating or
storing a context, so snapshot capture has no side effects. getValue()
keeps the pre-existing behavior of treating an empty SecurityContext
as absent, applied only when the context is already materialized (an
uninvoked deferred Supplier is propagated uninspected). The default
peekDeferredContext() implementation materializes the current context
eagerly, preserving pre-7.1 behavior for custom strategies.
setDeferredContext() no longer re-wraps an already-validated Supplier,
so micrometer's save/restore cycle does not accumulate wrappers.

Closes spring-projectsgh-18059

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Oldřich Jedlička <oldium.pro@gmail.com>
@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label Jul 9, 2026
@oldium

oldium commented Jul 9, 2026

Copy link
Copy Markdown
Author

Just a note, this is not AI generated slop, it is completely human driven and human reviewed. Claude acted as a skillful assistant implementing the ideas, this is actually the third version of the code. First sketch was done with Opus 4.7, then updated with Opus 4.8 (added more tests) and now finished with Fable 5 (added back empty context handling into the Accessor) - and I am finally satisfied with the solution.

The tests in the first commit expose the reported issue - they fail before the fix from commit 2 is applied.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: waiting-for-triage An issue we've not yet triaged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Possible StackOverflowError with io.micrometer:context-propagation and Lettuce Redis backend and Web Session in Redis

2 participants