TLDR: Implemented SSE streaming for LLM responses, but second event onwards hit "Access Denied". Root cause: SecurityContext not propagating to ASYNC dispatches. Fix: One line of Spring Security config.
Background: Why SSE?
Recently I improved our LLM response mechanism by introducing SSE (Server-Sent Events). Previously, we returned complete API responses in one shot. Now we deliver LLM responses via Flux-based streaming to the client.
- Flux-based streaming: Reactive Streams API from Spring WebFlux that delivers data streams asynchronously in multiple chunks
- SSE (Server-Sent Events): HTTP-based unidirectional streaming where server continuously pushes data to client
In other words, when delivering LLM responses, we're not using a traditional single HTTP request/response structure. Instead, we maintain one connection and deliver multiple events in real-time using SSE.
The Problem
The first API request worked perfectly. The Dispatcher Type was REQUEST, and JwtAuthenticationFilter validated the cookie properly, storing authentication info in SecurityContextHolder.
But starting from the second SSE event, problems emerged. The second request came in with Dispatcher Type ASYNC, bypassing the existing JwtAuthenticationFilter. As a result, SecurityContextHolder had no authentication info. Despite being the same URL, the security context was empty, causing an Access Denied error.
Root Cause Analysis
Spring Security manages SecurityContext throughout the request lifecycle. In normal requests (REQUEST), authentication info is stored properly through the filter chain. However, for asynchronous requests (ASYNC) or re-dispatches (e.g., FORWARD, INCLUDE), the security context may not propagate automatically.
In SSE and other async response environments, basic security configuration alone doesn't guarantee context propagation, leading to Access Denied issues.
The Solution
The fix is configuring SecurityContextRepository to propagate the session (technically the context within request scope). We can use this option provided in Spring Security 5.8+:
http.securityContext(context -> context.requireExplicitSave(false));
With this option, SecurityContext is automatically saved to SecurityContextRepository when changed, and the context propagates across re-dispatches (REQUEST, ASYNC, FORWARD, INCLUDE) within the same request.
What is SecurityContextRepository?
SecurityContextRepository is the strategy interface for storing/restoring SecurityContext in Spring Security. Behavior varies by implementation, but in SSE environments, it typically stores context in HttpServletRequest attributes (request scope) to maintain authentication info across async processing.
This approach maintains stateless operation (no sessions) while safely sharing security context across re-dispatches within a single HTTP request.
Understanding requireExplicitSave
Since Spring Security 5.8, you can explicitly control how SecurityContext is saved:
requireExplicitSave(false) (Auto-save mode, default)
- Automatically saves to
SecurityContextRepositorywhenSecurityContextchanges - No manual save code needed
- Same as legacy Spring Security behavior
requireExplicitSave(true) (Explicit save mode)
- Must explicitly call
SecurityContextRepository.saveContext()after context changes - No automatic saving
- Performance benefits, but developers must manage save timing
Since our system isn't complex yet, we decided auto-save mode (requireExplicitSave(false)) is more appropriate.
Why Re-dispatch Happens in SSE
Re-dispatching in SSE stems from Servlet 3.0 async processing spec's core design principle.
When async work completes, the result must go through the existing filter/servlet chain for consistent processing—hence the mandatory re-dispatch.
Without re-dispatch, the async request lifecycle wouldn't complete properly, causing resource leaks, connection management issues, security context mismatches, and other problems.
Therefore, Servlet 3.0 spec mandates re-dispatch after all async work completes, classifying such requests as DispatcherType.ASYNC.
Verifying the Call Order
To confirm when data is sent and when threads separate, I wrote a simple example.
In Spring MVC with SSE, controllers return Flux streams instead of regular ResponseEntity:
@PostMapping(value = "/gpt4/v3/test", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> test() throws Exception {
return Flux.just("data1", "data2", "data3")
.delayElements(Duration.ofSeconds(1))
.doOnComplete(() -> {
System.out.println("All data sent");
})
.doFinally(signalType -> {
System.out.println("Stream cleanup started: " + signalType);
});
}
This code maintains the connection with the client while sending data sequentially at 1-second intervals.
Instead of terminating the request immediately, it delivers responses asynchronously using Servlet 3.0's AsyncContext.
Tracking Async Transitions
I created AsyncDebugFilter to precisely identify the async transition point:
@Slf4j
@Component
public class AsyncDebugFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
throws IOException, ServletException {
...
HttpServletRequestWrapper requestWrapper = new HttpServletRequestWrapper(req) {
...
@Override
public AsyncContext startAsync(ServletRequest servletRequest, ServletResponse servletResponse) {
AsyncContext ac = super.startAsync(servletRequest, servletResponse);
attachListener(ac, requestId);
log.info("[{}] startAsync(req,res) called. DispatcherType={}, Thread={}",
requestId, super.getDispatcherType(), Thread.currentThread().getName());
return ac;
}
private void attachListener(AsyncContext ac, String requestId) {
ac.addListener(new AsyncListener() {
@Override
public void onComplete(AsyncEvent event) {
log.info("[{}] AsyncListener.onComplete - Thread={}", requestId, Thread.currentThread().getName());
}
...
});
}
}
HttpServletResponseWrapper responseWrapper = new HttpServletResponseWrapper(res) {
private ServletOutputStream loggingStream;
@Override
public ServletOutputStream getOutputStream() throws IOException {
if (loggingStream == null) {
final ServletOutputStream original = super.getOutputStream();
loggingStream = new ServletOutputStream() {
...
@Override
public void write(byte[] b, int off, int len) throws IOException {
log.debug("[{}] output.write(byte[{}]) thread={} len={}", requestId, b.length, Thread.currentThread().getName(), len);
original.write(b, off, len);
}
@Override
public void flush() throws IOException {
log.debug("[{}] output.flush thread={}", requestId, Thread.currentThread().getName());
original.flush();
}
...
};
}
return loggingStream;
}
...
};
}
}
This filter intercepts request.startAsync() calls and registers an AsyncListener, allowing us to track exactly when async processing begins.
The Execution Flow
First, let's examine the call sequence. When a request arrives, it passes through security filters into business logic.
When it hits async logic, startAsync is called to separate the thread. (→ This is the async transition point)
After data transmission completes, doOnComplete and doFinally are called sequentially, then the servlet container performs ASYNC re-dispatch.
Without ASYNC re-dispatch, the request would remain open.
Therefore, the container must perform ASYNC dispatch to properly terminate the request, ensuring connections are cleaned up correctly.
Conclusion
The Access Denied issue after implementing SSE was caused by Spring Security's behavior of not propagating SecurityContext in async requests.
By applying http.securityContext(context -> context.requireExplicitSave(false)) to utilize SecurityContextRepository, we can reliably maintain authentication info even in SSE environments.
If the system becomes more complex and performance optimization becomes necessary, we could consider explicit save mode (requireExplicitSave(true)). But for now, auto-save mode provides stable operation.
Key Takeaways
- SSE uses Servlet 3.0 async processing, which requires ASYNC re-dispatch after completion
- SecurityContext doesn't auto-propagate across dispatcher types by default
-
One config line fixes it:
requireExplicitSave(false)enables automatic context propagation - No sessions needed: Uses request-scoped attributes for stateless context sharing
- AsyncDebugFilter is your friend: Helps visualize the complete async lifecycle
Tags: #spring-security #sse #async #spring-boot #webflux #security-context #servlet
📧 Questions? Reach me at: [junyoungmoon9857@gmail.com]






Top comments (0)