HTTP Strict Transport Security sounds simple: send one response header and browsers stop using HTTP for your site.
In practice, Java teams still get it wrong. I’ve seen Spring Boot apps enable HSTS in one environment, forget it behind a reverse proxy, then wonder why production behavior doesn’t match local testing.
If you run a Spring Boot app over HTTPS, HSTS is usually the right move. The real question is how to enable it, how aggressive to be, and when not to turn on the stricter options.
What HSTS does
The header looks like this:
Strict-Transport-Security: max-age=31536000; includeSubDomains
When a browser sees it over a valid HTTPS connection, it remembers:
- only use HTTPS for this host
- optionally apply the rule to all subdomains
- optionally preload the site into browser lists
That means fewer downgrade attacks and fewer accidental insecure requests.
The main ways to use HSTS in Spring Boot
For Spring Boot apps, I’d break it into three common approaches:
- Use Spring Security defaults
- Customize HSTS with Spring Security
- Set HSTS at the reverse proxy or load balancer instead
Each one has tradeoffs.
Option 1: Use Spring Security defaults
If you have Spring Security enabled, HSTS support is already built in.
Example
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults());
return http.build();
}
}
With the default Spring Security header configuration, HSTS is typically enabled for secure requests.
Pros
- Minimal code. You get sane defaults without reinventing header logic.
- Maintained by Spring Security. Less custom code means fewer weird mistakes.
- Easy to keep consistent across services.
Cons
- Defaults can be invisible. Teams often don’t realize HSTS is active until they inspect headers.
- Behavior depends on secure requests. If Spring doesn’t think the request is HTTPS, HSTS may not be added.
- Less explicit. I prefer security-critical behavior to be obvious in config, not implied.
Best fit
Use this when:
- you already use Spring Security
- your app is directly aware it’s behind HTTPS
- you want standard behavior with low maintenance
If you want to verify what your app is actually returning, run a header scan. A quick test with HeaderTest is often faster than reading config and guessing.
Option 2: Customize HSTS in Spring Security
This is the option I usually recommend for production apps. You keep the framework support, but make the policy explicit.
Example: set a one-year policy with subdomains
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.headers(headers -> headers
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000)
.includeSubDomains(true)
)
);
return http.build();
}
}
That produces something close to:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Example: disable HSTS in local development
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@Profile("dev")
public class DevSecurityConfig {
@Bean
SecurityFilterChain devSecurityFilterChain(HttpSecurity http) throws Exception {
http
.headers(headers -> headers
.httpStrictTransportSecurity(hsts -> hsts.disable())
);
return http.build();
}
}
Pros
- Explicit policy. Nobody has to guess your max-age or subdomain coverage.
- Safer rollout. You can start with a short max-age, then increase it.
- Works well with environment-specific config.
Cons
- More decisions to make. Teams can overcomplicate this.
- Easy to break with proxy misconfiguration. If forwarded HTTPS headers aren’t handled correctly, HSTS might not be sent.
- Bad settings can be sticky. Browsers cache HSTS. A careless policy can haunt you for months.
Best fit
Use this when:
- you care about predictable production behavior
- you have multiple environments
- you want to document security policy in code
This is the sweet spot for most Spring Boot teams.
Option 3: Set HSTS at the reverse proxy or load balancer
Sometimes the app is not the best place to manage response headers. If TLS terminates at NGINX, Apache, or a cloud load balancer, you may prefer to inject HSTS there.
In that setup, Spring Boot doesn’t send the header itself.
Pros
- Centralized control. Great if many apps sit behind the same edge layer.
- Closer to TLS termination. That’s often the cleanest place for transport security policy.
- Consistent across mixed stacks. Java, Node, Go, static sites — same HSTS policy.
Cons
- App developers lose visibility. Security policy drifts into infrastructure config.
- Harder to test locally. The app may look insecure unless you test through the real proxy path.
- Split responsibility. App team thinks ops owns it, ops thinks app team owns it.
Best fit
Use this when:
- your organization standardizes security headers at the edge
- multiple services must share one policy
- the app doesn’t directly manage TLS
My opinion: this is fine, but only if ownership is crystal clear. Otherwise HSTS falls into a gap and disappears.
Comparing the options
Spring Security defaults
Pros
- simplest setup
- low maintenance
- secure enough for many apps
Cons
- not obvious in code
- can fail silently behind proxies
- less control over rollout
Custom Spring Security config
Pros
- explicit
- flexible
- best balance for most teams
Cons
- extra config
- easier to misconfigure if you don’t understand HSTS
- requires thought around environments
Reverse proxy / load balancer
Pros
- centralized
- stack-agnostic
- strong fit for platform teams
Cons
- less visible to app developers
- local testing friction
- ownership confusion is common
The real risk: includeSubDomains
This flag is where teams get burned.
Strict-Transport-Security: max-age=31536000; includeSubDomains
Looks great on paper. But if you have:
- old internal subdomains
- forgotten admin hosts
- staging systems on plain HTTP
- third-party services under your domain
then includeSubDomains can break them for browsers that have cached the policy.
I only enable it when I know the entire subdomain space is HTTPS-ready.
What about preload?
You can add:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
This asks browsers to hardcode your domain into preload lists, assuming you also meet browser preload requirements.
Pros
- strongest protection against first-visit downgrade attacks
- great for mature, HTTPS-only domains
Cons
- painful to undo
- unforgiving if any subdomain is not HTTPS-clean
- not something I’d enable casually on a busy domain
Preload is for organizations with strong operational discipline. If you’re still cleaning up legacy hosts, skip it.
For Spring Security, that would look like:
http.headers(headers -> headers
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000)
.includeSubDomains(true)
.preload(true)
)
);
Spring Boot gotcha: proxies and forwarded headers
This is the bug I see most often.
Your app runs behind a proxy that terminates TLS. The browser connects with HTTPS, but the Spring Boot app receives plain HTTP from the proxy. Spring then thinks the request is insecure and may skip HSTS.
You need forwarded header handling configured correctly so Spring understands the original scheme.
For newer Spring Boot setups, this is commonly handled with:
server.forward-headers-strategy=framework
```text
Then make sure your proxy sends the expected forwarded headers.
If you want the exact behavior and current options, check the official Spring Boot docs:
[https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/](https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/)
And for Spring Security header configuration:
[https://docs.spring.io/spring-security/reference/servlet/exploits/headers.html](https://docs.spring.io/spring-security/reference/servlet/exploits/headers.html)
---
## My recommended rollout for Spring Boot teams
If I were setting this up for a typical production Java service, I’d do this:
1. **Enable HSTS explicitly in Spring Security**
2. **Start with a shorter max-age**, like a few hours or days
3. **Verify behavior through the real HTTPS entry point**
4. **Increase to one year** once you trust it
5. **Add `includeSubDomains` only after an inventory check**
6. **Use `preload` only for mature domains**
### Example phased config
Start cautious:
http.headers(headers -> headers .httpStrictTransportSecurity(hsts -> hsts .maxAgeInSeconds(86400) .includeSubDomains(false) ) );
Then move to a stronger policy:
http.headers(headers -> headers .httpStrictTransportSecurity(hsts -> hsts .maxAgeInSeconds(31536000) .includeSubDomains(true) ) );
That path is a lot safer than jumping straight to preload because somebody saw a checklist on a scanner report.
## My take
For most Spring Boot apps, **custom Spring Security HSTS config is the best option**.
It’s clearer than relying on defaults and less fragmented than pushing everything into infrastructure. You can still centralize policy later if your platform team wants that.
Just don’t treat HSTS as a box-ticking header. It changes browser behavior in persistent ways. That’s exactly why it’s useful — and exactly why sloppy rollouts cause pain.