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:

  1. Use Spring Security defaults
  2. Customize HSTS with Spring Security
  3. 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.