← § BLOG

SSE + Spring Security: three async-dispatch traps that break tool-streaming

I shipped SSE for tool-streaming an LLM and ran into three separate Spring Security bugs around async processing. Breakdown of each and the minimum correct code.

#spring-boot#spring-security#sse#java

In my assistant on top of the OLT monitoring system, every conversation streams to the browser via Server-Sent Events: tool calls, arguments, results, model output. Without streaming the user stares at a blank screen for 6 seconds; with it they watch the bot build its answer.

Locally everything worked. On staging — AccessDenied 403 exactly at SSE finalization, after events had already gone out. The log cut off on the closing complete(). It took half a day and three separate commits before I realized this was three different bugs in three different layers, and all three had to be closed at once.

How SSE works in Spring MVC

The controller returns SseEmitter, and Spring processes the request in two passes:

  1. REQUEST dispatch — normal HTTP cycle: filters, authentication, controller. It finishes as soon as the method returns the SseEmitter — but the connection stays open.
  2. ASYNC dispatch — a separate worker thread writes events to the response via emitter.send(...). It lives as long as it wants, then calls emitter.complete().
  3. Final dispatch — Spring re-runs the request through filters with DispatcherType.ASYNC for post-processing. Here my SecurityFilterChain triggered again and re-checked auth — but SecurityContext was no longer in the worker.

Each of the three steps is a candidate to break.

Trap 1: @PreAuthorize on the SSE endpoint breaks async dispatch

@GetMapping(value = "/api/llm/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@PreAuthorize("isAuthenticated()")  // ← this is what breaks it
public SseEmitter chat(@RequestParam String message) {
    var emitter = new SseEmitter(0L);
    chatService.runAsync(message, emitter);
    return emitter;
}

@PreAuthorize is an AOP aspect that checks SecurityContext on every dispatch. On REQUEST everything's fine: the user is authenticated. On ASYNC dispatch — context is empty, the aspect throws AccessDeniedException, Spring responds 403 on top of the stream that's already live.

I removed the annotation and check SecurityContextHolder.getContext().getAuthentication() by hand at the start of the method:

@GetMapping("/api/llm/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chat(@RequestParam String message) {
    var auth = SecurityContextHolder.getContext().getAuthentication();
    if (auth == null || !auth.isAuthenticated()) {
        throw new AccessDeniedException("auth required");
    }
    // ...
}

Auth is checked once on REQUEST, async dispatch doesn't pick up the aspect. Better, but not cured.

Trap 2: SecurityContext is lost in the worker thread

chatService.runAsync runs the tool loop in a separate thread via @Async or Executor.submit(). The thread is fresh — ThreadLocal is empty. If the loop calls a tool that itself goes somewhere with permission checks (@PreAuthorize on a service), AccessDenied flies again, this time from inside the worker.

The fix lives in Spring Security's standard library — DelegatingSecurityContextRunnable:

import org.springframework.security.concurrent.DelegatingSecurityContextRunnable;

public void runAsync(String message, SseEmitter emitter) {
    var ctx = SecurityContextHolder.getContext();
    executor.submit(new DelegatingSecurityContextRunnable(
        () -> tryRun(message, emitter),
        ctx
    ));
}

The wrapper copies SecurityContext into the worker thread before run and clears it after. The same effect is available via DelegatingSecurityContextExecutor — wrap the Executor itself, no manual wrapping each time.

Trap 3: SecurityFilterChain hits DispatcherType.ASYNC

This one is the trickiest. After the worker finishes, Spring does a final async dispatch for post-processing. The request goes through SecurityFilterChain again — but it's the same request, authentication already happened. If a filter tries to extract the principal again (a JwtAuthenticationFilter, say), it'll either fail or try to re-authenticate with an empty Authorization header (it was stripped on REQUEST after login).

In my case JwtAuthenticationFilter couldn't find a token and returned AccessDenied. The fix — skip async/error dispatch types:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .securityMatcher(req ->
            req.getDispatcherType() == DispatcherType.REQUEST
        )
        // ... everything else as before
        .build();
}

securityMatcher checking DispatcherType.REQUEST means: the filter chain only runs on the first dispatch. Async and error are skipped — they're already on an authorized request, a re-check is redundant and harmful.

What I learned

  1. SSE is three dispatches, not one. REQUEST, ASYNC, and the final async-after-complete. Any Security logic must understand which of the three it's running on.
  2. Don't slap @PreAuthorize on an SSE endpoint. The annotation-aspect fires on every dispatch. Check auth once by hand and propagate SecurityContext into the worker.
  3. DelegatingSecurityContextRunnable is the standard, not a hack. Spring Security ships it precisely for async scenarios. Don't invent your own ThreadLocal propagator.
  4. securityMatcher(req → DispatcherType.REQUEST) is must-have for projects with SSE/WebFlux/async. Without it any post-processing dispatch can knock down a request that already responded.

If SSE dies with AccessDenied a second after it already sent data — look at async dispatch, not the endpoint.

Share
Discussion

Comments are powered by Giscus + GitHub. Clicking transfers data to GitHub Inc. (USA). No click — no transfer.

SSE + Spring Security: three async-dispatch traps that break tool-streaming · Grigoriy Masich