In this post, I’d like to share a lesson learned by one of the teams at O&B. They were using Spring Boot with Spring Security.
By default, anything that is protected by Spring Security is sent to the browser with the following HTTP header:
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Essentially, the response will never be cached by the browser. While this may seem inefficient, there is actually a good reason for this default behavior. When one user logs out, we don’t want the next logged in user to be able to see the previous user’s resources (and this is possible if they’re cached).
It makes sense to not cache anything by default, and leave caching to be explicitly enabled. But it’s not good if nothing is cached, as it will lead to high bandwidth usage and slow page loads.
Good thing it is very easy to enable caching of static content in Spring Boot (even with Spring Security). Simply configure a cache period. And that’s it!
# Boot 2.x spring.resources.cache.cachecontrol.max-age=14400 # Boot 1.x spring.resources.cache-period=14400
But there are some gotchas! With some versions, it ain’t that simple! Let me explain further.
There are several ways content can be returned:
- Static content through Spring Boot auto-configured static resource request handler
- Controller method returning view name (e.g. resolves to a JSP)
- Controller method returning
HttpEntity
(orResponseEntity
)
Enable Caching of Static Content
The first (serving static content) is handled by configuring the said property (usually in application.properties
as shown above).
Set via HttpServletResponse
In the second case, the controller handler method may choose to set "Cache-Control" headers through a HttpServletResponse
method parameter.
@Controller ... class ... { @RequestMapping(...) public String ...(..., HttpServletResponse response) { response.setHeader("Cache-Control", "max-age=14400"); return ...; // view name } }
This works, as long as Spring Security does not overwrite it.
Set via HttpEntity/ResponseEntity
In the third case, the controller handler method may choose to set "Cache-Control" headers of the returned HTTP entity.
@Controller ... class ... { @RequestMapping(...) public ResponseEntity<...> ...(...) { return ResponseEntity.ok().cacheControl(...).body(...); } }
This works, as long as Spring Security has not written its own "Cache-Control" headers yet.
Under the Hood
To understand when and why it works, here are the relevant sequences.
With Spring Security Web 4.0.x, 4.2.0 up to 4.2.4 and above, the following sequence occurs:
- The
HeaderWriterFilter
delegates toCacheControlHeadersWriter
to write the "Cache-Control" headers (including "Pragma" and "Expires"), if no cache headers exist. -
Controller handler method (if matched) is invoked. The method can:
- Explicitly set a header in
HttpServletResponse
. - Or, set a header in the returned
HttpEntity
orResponseEntity
(refer to thehandleReturnValue()
method ofHttpEntityMethodProcessor
).- Note that
HttpEntityMethodProcessor
only writes the headers (fromHttpEntity
) to the actual response if they do not exist yet. This becomes a problem, since back in #1, the headers have already been set.
- Note that
- Explicitly set a header in
- If no controller handles the request, then the Spring Boot auto-configured static resource request handler gets its chance. It tries to serve static content, and if configured to cache, it overwrites the "Cache-Control" headers (and clears the values of "Pragma" and "Expires" headers, if any). The static resource handler is a
ResourceHttpRequestHandler
object (refer to theapplyCacheControl()
method in itsWebContentGenerator
base class).- However, in Spring Web MVC 4.2.5, the
WebContentGenerator
only writes the "Cache-Control" headers only if it does not exist!. This becomes a problem, since back in #1, the headers have already been set. - In Spring Web MVC 4.2.6 and above, it adds the "Cache-Control" headers even if it already exists. So, no problem even if the headers have been set in #1.
- However, in Spring Web MVC 4.2.5, the
With Spring Security Web 4.1.x, 4.2.5, and above (version 4.2.5 is used in Spring Boot 1.5.11), the sequence has changed. It goes something like this:
-
Controller handler method (if matched) is invoked. The method can:
- Explicitly set a header in
HttpServletResponse
. - Or, set a header in the returned
HttpEntity
orResponseEntity
(refer to thehandleReturnValue()
method ofHttpEntityMethodProcessor
).- Note that
HttpEntityMethodProcessor
only writes the headers (fromHttpEntity
) to the actual response if they do not exist yet. No problem, since no headers have been set yet.
- Note that
- Explicitly set a header in
- If no controller handles the request, then the Spring Boot auto-configured static resource request handler gets its chance. It tries to serve static content, and if configured to cache, it overwrites the "Cache-Control" headers (and clears the values of "Pragma" and "Expires" headers, if any).
- The
HeaderWriterFilter
delegates toCacheControlHeadersWriter
to write the "Cache-Control" headers (including "Pragma" and "Expires"), if no cache headers exist.- No problem, since it will not overwrite if cache headers have already been set.
Working Versions
The above three cases of controlling caching all work in Spring Boot 1.5.11 and Spring Boot 2.x. But in case upgrading to those versions is not possible, please see the following classes and check if it has your desired behavior (using the above sequences):
HeaderWriterFilter
(seedoFilterInternal
method)CacheControlHeadersWriter
(seewriteHeaders()
method)WebContentGenerator
(seeapplyCacheControl()
method)HttpEntityMethodProcessor
(seehandleReturnValue()
method)
Also, be aware that Spring Security Web 4.2.5 and above will write the following HTTP headers (overwrite it, even if they are already set, like in a controller for example):
X-Content-Type-Options
viaXContentTypeOptionsHeaderWriter
Strict-Transport-Security
viaHstsHeaderWriter
X-Frame-Options
viaXFrameOptionsHeaderWriter
X-XSS-Protection
viaXXssProtectionHeaderWriter
This is because, unlike CacheControlHeadersWriter
, the header writers for the above do not check if the headers already exist. They simply set their respective HTTP headers. Please refer to their respective header writer classes and issue #5193.
Another option is to have Spring Security ignore static resource requests. That way, the configured cache period will not be overwritten.
@Configuration @EnableWebSecurity public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/css/**", "/js/**"); // If the above paths are served by the // Spring Boot auto-configured // static resource request handler, // and a cache period is specified, // then it will have a "Cache-Control" // HTTP header in its response. // And it would NOT get overwritten by Spring Security. } }
That's all for now. Hope this clears things up.