Describe the bug
When using HeaderWriterFilter
and asynchronous processing (e. g. a Spring MVC controller method that returns a StreamingResponseBody
), headers may be set twice from different threads, potentially leading to race conditions and undefined behaviour.
The following two things happen in parallel:
- The asynchronous processing writes to the response body. After data has been written, the response is committed. This causes the
HeaderWriterFilter
to write headers from the asynchronous task's thread. - The execution of the
HeaderWriterFilter
on the way back through the filter chain. This will write headers from the original request handling thread.
Both threads may successfully pass the isDisableOnResponseCommitted()
check in HeaderWriterFilter.HeaderWriterResponse#writeHeaders
, causing all HeaderWriter
s to be invoked twice. This may cause duplicate headers, because even if the HeaderWriter
checks if the header is already present, this check-and-add is usually not atomic.
It's getting even worse: The implementation of HttpServletResponse
is not guaranteed to be thread safe according to the Servlet Spec.
When using Apache Tomcat, adding headers from different threads causes nasty race conditions that (because of Tomcat's instance recycling) can durably break the inner workings of their org.apache.tomcat.util.http.MimeHeaders
class, potentially leading to errors in completely unrelated requests. I already experienced duplicate and missing response headers. As with most race conditions, these specific issues are almost impossible to reproduce.
Because other parts of the application may also add headers, this issue cannot probably be solved inside the HeaderWriterFilter
alone.
To make things even worse, Tomcat itself also manipulates response headers internally: For example, the Vary
response headers created by the application are combined into one if response compression is enabled. This happens in the same thread where the response is committed, but is probably out of Spring Security's reach.
To Reproduce
Create a Spring MVC (or Spring Boot) application that uses HeaderWriterFilter
and has a @Controller
method that returns a StreamingResponseBody
. This is already sufficient to reproduce the duplicate headers.
Adding a HeaderWriter
that adds a lot of headers helps reproducing the errors caused by adding headers from different threads.
Expected behavior
HeaderWriterFilter
doesn't invoke its HeaderWriter
s from different threads at the same time. However, this might not be sufficient, as described above.
Sample
https://github.com/chschu/spring-security-header-writer-filter-async-bug
The sample also includes a workaround: Flush the response programmatically before starting the asynchronous processing.
Comment From: paulomi-mahidharia-eb
I’m observing a similar issue in our Spring Boot application. One of our endpoints returns a StreamingResponseBody, which triggers asynchronous request handling. As you described, response headers are occasionally duplicated—though it happens randomly and rarely.
I attempted to deduplicate headers in a filter before the response is committed, but this approach isn’t reliable. From the filter logs, I noticed that getOutputStream().flush() is invoked multiple times, which interrupts the deduplication process. In some cases, not all headers are even present in the ServletResponse at the time the filter runs, since the response hasn’t yet been committed. And once response is committed, I cannot dedup them.
I also tried the suggested workaround of forcing a flush before starting the async processing. However, I still see HeaderWriterFilter being invoked later within the async thread whenever getOutputStream().flush() is called, which reintroduces the headers despite the flush.
Today, I explicitly call flush() on the streaming output once I have processed the data from Iterator, and I also tried switching to a BufferedWriter to avoid calling flush() directly. However, I still see it being invoked implicitly.
One last thing I am trying is to disable the headers in SpringSecurity and explicity add them via a filter. Yet to try it though.
Exasmple logs (RemoveDuplicateAndLogHeadersFilter is the filter that dedups (if response is not committed) and logs headers whenever getOutputStream().flush() is invoked):
2025-09-28 04:21:32.746
2025-09-28 08:21:32.702 [http-nio-8080-exec-6] [example-request-id] [system-source-id] [my-test] [313639986790454] [] - INFO my-app.AsyncConfig - Container thread has completed
2025-09-28 04:21:32.746
2025-09-28 08:21:32.699 [asyncExecutor-20] [example-request-id] [system-source-id] [my-test] [313639986790454] [] - INFO my-app.AsyncConfig - async task execution time: 5ms
2025-09-28 04:21:32.746
2025-09-28 08:21:32.699 [asyncExecutor-20] [example-request-id] [system-source-id] [my-test] [313639986790454] [] - INFO c.e.c.a.f.RemoveDuplicateAndLogHeadersFilter - [Final Response Header] Date = Sun, 28 Sep 2025 08:21:32 GMT (thread: asyncExecutor-20, URI: /contact-search/v3/stream)
2025-09-28 04:21:32.746
2025-09-28 08:21:32.699 [asyncExecutor-20] [example-request-id] [system-source-id] [my-test] [313639986790454] [] - INFO c.e.c.a.f.RemoveDuplicateAndLogHeadersFilter - [Final Response Header] Transfer-Encoding = chunked (thread: asyncExecutor-20, URI: /contact-search/v3/stream)
2025-09-28 04:21:32.746
2025-09-28 08:21:32.699 [asyncExecutor-20] [example-request-id] [system-source-id] [my-test] [313639986790454] [] - INFO c.e.c.a.f.RemoveDuplicateAndLogHeadersFilter - [Final Response Header] Content-Type = application/stream+json (thread: asyncExecutor-20, URI: /contact-search/v3/stream)
2025-09-28 04:21:32.746
2025-09-28 08:21:32.699 [asyncExecutor-20] [example-request-id] [system-source-id] [my-test] [313639986790454] [] - INFO c.e.c.a.f.RemoveDuplicateAndLogHeadersFilter - [Final Response Header] X-Frame-Options = DENY (thread: asyncExecutor-20, URI: /contact-search/v3/stream)
2025-09-28 04:21:32.746
2025-09-28 08:21:32.699 [asyncExecutor-20] [example-request-id] [system-source-id] [my-test] [313639986790454] [] - INFO c.e.c.a.f.RemoveDuplicateAndLogHeadersFilter - [Final Response Header] Expires = 0 (thread: asyncExecutor-20, URI: /contact-search/v3/stream)
2025-09-28 04:21:32.746
2025-09-28 08:21:32.699 [asyncExecutor-20] [example-request-id] [system-source-id] [my-test] [313639986790454] [] - INFO c.e.c.a.f.RemoveDuplicateAndLogHeadersFilter - [Final Response Header] Pragma = no-cache (thread: asyncExecutor-20, URI: /contact-search/v3/stream)
2025-09-28 04:21:32.746
2025-09-28 08:21:32.699 [asyncExecutor-20] [example-request-id] [system-source-id] [my-test] [313639986790454] [] - INFO c.e.c.a.f.RemoveDuplicateAndLogHeadersFilter - [Final Response Header] Cache-Control = no-cache, no-store, max-age=0, must-revalidate (thread: asyncExecutor-20, URI: /contact-search/v3/stream)
2025-09-28 04:21:32.746
2025-09-28 08:21:32.699 [asyncExecutor-20] [example-request-id] [system-source-id] [my-test] [313639986790454] [] - INFO c.e.c.a.f.RemoveDuplicateAndLogHeadersFilter - [Final Response Header] X-XSS-Protection = 0 (thread: asyncExecutor-20, URI: /contact-search/v3/stream)
2025-09-28 04:21:32.746
2025-09-28 08:21:32.699 [asyncExecutor-20] [example-request-id] [system-source-id] [my-test] [313639986790454] [] - INFO c.e.c.a.f.RemoveDuplicateAndLogHeadersFilter - [Final Response Header] X-XSS-Protection = 0 (thread: asyncExecutor-20, URI: /contact-search/v3/stream)
2025-09-28 04:21:32.746
2025-09-28 08:21:32.699 [asyncExecutor-20] [example-request-id] [system-source-id] [my-test] [313639986790454] [] - INFO c.e.c.a.f.RemoveDuplicateAndLogHeadersFilter - [Final Response Header] X-Content-Type-Options = nosniff (thread: asyncExecutor-20, URI: /contact-search/v3/stream)
2025-09-28 04:21:32.746
2025-09-28 08:21:32.699 [asyncExecutor-20] [example-request-id] [system-source-id] [my-test] [313639986790454] [] - INFO c.e.c.a.f.RemoveDuplicateAndLogHeadersFilter - [Final Response Header] request_id = example-request-id (thread: asyncExecutor-20, URI: /contact-search/v3/stream)
2025-09-28 04:21:32.746
2025-09-28 08:21:32.698 [asyncExecutor-20] [example-request-id] [system-source-id] [my-test] [313639986790454] [] - WARN c.e.c.a.f.RemoveDuplicateAndLogHeadersFilter - getOutputStream().flush() called after response is already committed (thread: asyncExecutor-20, URI: /contact-search/v3/stream)
2025-09-28 04:21:32.746
2025-09-28 08:21:32.698 [asyncExecutor-20] [example-request-id] [system-source-id] [my-test] [313639986790454] [] - INFO c.e.c.a.f.RemoveDuplicateAndLogHeadersFilter - [Final Response Header] X-Content-Type-Options = nosniff (thread: asyncExecutor-20, URI: /contact-search/v3/stream)
2025-09-28 04:21:32.746
2025-09-28 08:21:32.698 [asyncExecutor-20] [example-request-id] [system-source-id] [my-test] [313639986790454] [] - INFO c.e.c.a.f.RemoveDuplicateAndLogHeadersFilter - [Final Response Header] request_id = example-request-id (thread: asyncExecutor-20, URI: /contact-search/v3/stream)
2025-09-28 04:21:32.746
2025-09-28 08:21:32.698 [asyncExecutor-20] [example-request-id] [system-source-id] [my-test] [313639986790454] [] - INFO c.e.c.a.f.RemoveDuplicateAndLogHeadersFilter - getOutputStream().flush() - Deduplicating 3 headers (thread: asyncExecutor-20, URI: /contact-search/v3/stream)
2025-09-28 04:21:32.746
2025-09-28 08:21:32.695 [asyncExecutor-20] [example-request-id] [system-source-id] [my-test] [313639986790454] [] - INFO c.e.c.a.f.RemoveDuplicateAndLogHeadersFilter - getOutputStream().flush() - Deduplicating headers before committing (thread: asyncExecutor-20, URI: /contact-search/v3/stream)
2025-09-28 04:21:32.746
2025-09-28 08:21:32.695 [http-nio-8080-exec-6] [example-request-id] [system-source-id] [my-test] [313639986790454] [] - INFO c.e.c.a.filter.LoggingContextFilter - request execution time: 13ms
2025-09-28 04:21:32.746
2025-09-28 08:21:32.694 [http-nio-8080-exec-6] [example-request-id] [system-source-id] [my-test] [313639986790454] [] - INFO c.e.c.a.f.RemoveDuplicateAndLogHeadersFilter - Adding header 'Content-Type' to value 'application/stream+json' (thread: http-nio-8080-exec-6)
Any inputs are appreciated. Thank you!