Explain HSTS (Strict-Transport-Security): Spring Security's defaults, its directives, and the gotchas around when it is emitted.
answer
- max-age + includeSubDomains + preload
- default 1 year, includeSubDomains true, preload false
- only on request.isSecure()
- proxy TLS termination → forward-headers-strategy
- preload = one-way door
basics
~10 sHSTS tells browsers to always use HTTPS for a host. Spring Security sends Strict-Transport-Security: max-age=31536000; includeSubDomains, but only on secure requests. Tune it via httpStrictTransportSecurity(hsts -> hsts.maxAgeInSeconds(...).includeSubDomains(...).preload(...)).
solid answer
~40 sHTTP Strict Transport Security instructs the browser to only ever contact a host over HTTPS for a given duration, defeating SSL-strip/downgrade and mixed-content attacks. Spring Security's `HstsHeaderWriter` sends `Strict-Transport-Security: max-age=31536000 ; includeSubDomains` by default — a one-year policy covering subdomains — but crucially **only when the request is secure** (`request.isSecure()`), because HSTS over plain HTTP is ignored. Directives: `max-age` (seconds the browser remembers), `includeSubDomains` (applies to all subdomains), and `preload` (opt-in to browser preload lists — set only when you truly commit, since removal is slow). Configure via `.httpStrictTransportSecurity(hsts -> hsts.maxAgeInSeconds(63072000).includeSubDomains(true).preload(true))`. The classic gotcha: behind a TLS-terminating load balancer, the app sees HTTP and skips HSTS unless you enable forwarded-headers handling (`server.forward-headers-strategy`) or override the secure-request matcher.
code
kotlin · 14 lines@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http.headers { headers ->
headers.httpStrictTransportSecurity { hsts ->
hsts.maxAgeInSeconds(63072000) // 2 years
.includeSubDomains(true)
.preload(true)
}
}
return http.build()
}
// Behind a TLS-terminating proxy, also set in application.yml:
// server.forward-headers-strategy: framework
// so X-Forwarded-Proto=https makes request.isSecure() true and HSTS is emitted.go deeper
Know HSTS forces HTTPS and is a default (on HTTPS).
Name the three directives and configure max-age/includeSubDomains.
Explain the request.isSecure() gate and the preload/max-age ramp-up risk.
Diagnose the TLS-terminating-proxy case and design a safe HSTS rollout policy.
## What HSTS solves Without HSTS, a user typing `example.com` first hits HTTP, giving an attacker on the network a window to **SSL-strip** (keep the victim on HTTP and proxy to the real HTTPS site) or downgrade the connection. **HTTP Strict Transport Security** closes that window: once a browser has seen the header, it refuses to talk to the host over anything but HTTPS until `max-age` expires — it upgrades requests internally and won't let the user click through cert warnings. ## The header and its directives `Strict-Transport-Security: max-age=<seconds> ; includeSubDomains ; preload` - **max-age** — how long (seconds) the browser enforces HTTPS-only. Spring's default is `31536000` (1 year). - **includeSubDomains** — extend the policy to every subdomain. Default **true** in Spring Security. Be careful: it locks *all* subdomains to HTTPS. - **preload** — opt into the browser-vendor **HSTS preload list** (hard-coded into browsers so even the first-ever request is HTTPS). Default **false**. Only enable when you meet the preload requirements and accept that de-listing is slow. ## Spring Security specifics - Writer: `HstsHeaderWriter`. - **Only emitted on secure requests.** It uses a `RequestMatcher` (default `SecureRequestMatcher`) that checks `HttpServletRequest.isSecure()`. Over plain HTTP the header is omitted — correct, because browsers ignore HSTS received over HTTP. - DSL: ```java http.headers(h -> h.httpStrictTransportSecurity(hsts -> hsts .maxAgeInSeconds(63072000) // 2 years .includeSubDomains(true) .preload(true))); ``` - Disable with `.httpStrictTransportSecurity(HstsConfig::disable)`. - You can change *when* it's sent with `.requestMatcher(...)` if you need custom secure detection. ## The proxy gotcha (most common production issue) In cloud/PaaS deployments (Railway, Heroku, behind nginx/ALB) TLS is terminated at the edge and the app receives plain HTTP. Then `request.isSecure()` is false and **HSTS is silently not sent**. Fix by making the app trust forwarded headers: set `server.forward-headers-strategy=framework` (or `native`), or configure a `ForwardedHeaderFilter`, so `X-Forwarded-Proto: https` makes the request appear secure. Alternatively override the HSTS request matcher. ## Additional gotchas - **Don't set a huge max-age before you're sure HTTPS is stable.** If HTTPS later breaks, users are locked out for the remembered duration. Ramp up (e.g. start at 300s, then increase). - **preload is a one-way door** in practice — removal from browser lists takes months. - includeSubDomains can strand a legacy HTTP-only subdomain. - HSTS is per-host and only meaningful for HTTPS origins; localhost dev over HTTP won't show it.
- You deployed behind a load balancer that terminates TLS and HSTS never appears. Why, and how do you fix it?The app receives plain HTTP from the balancer, so request.isSecure() is false and HstsHeaderWriter skips the header. Enable forwarded-headers handling (server.forward-headers-strategy=framework/native or a ForwardedHeaderFilter) so X-Forwarded-Proto=https is honored, making the request secure.
- Why should you be cautious about enabling `preload` with a large max-age immediately?preload bakes HTTPS-only into browsers' hard-coded lists and removal takes months; combined with a large max-age, any HTTPS misconfiguration locks users out for a long time. Ramp max-age up gradually and only preload once TLS is proven stable.
saying these in an interview costs you the question
- Thinking HSTS is sent over plain HTTP
- Not knowing the proxy/forwarded-proto interaction
- Treating preload as freely reversible
- Claiming includeSubDomains is off by default in Spring Security