HTTP Security Headers
Every security header worth setting: what each one prevents, the values to use, Spring configuration, and how to roll out a Content Security Policy without breaking the site.
On this page
Security headers are among the cheapest defences available — configuration rather than code — and they limit the damage of vulnerabilities you have not found yet.
Key Takeaways
- HSTS forces HTTPS at the browser, eliminating downgrade and cookie-theft-over-HTTP.
- CSP limits what injected content can do; roll it out in report-only mode first.
frame-ancestors 'none'prevents clickjacking; keepX-Frame-Optionsfor old clients.nosniffstops the browser guessing a content type and executing your uploads.Permissions-Policydisables device APIs your site never uses.
The set worth sending
| Header | Value | Prevents |
|---|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains; preload | HTTPS downgrade |
Content-Security-Policy | Nonce-based, see below | XSS impact, data exfiltration |
X-Content-Type-Options | nosniff | MIME sniffing |
X-Frame-Options | DENY | Clickjacking (legacy clients) |
Referrer-Policy | strict-origin-when-cross-origin | URL leakage to third parties |
Permissions-Policy | camera=(), microphone=(), geolocation=() | Unwanted device access |
Cache-Control | no-store on authenticated pages | Cached private data |
Cross-Origin-Opener-Policy | same-origin | Cross-window attacks |
Spring configuration
@Bean
SecurityFilterChain headers(HttpSecurity http) throws Exception {
http.headers(headers -> headers
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.preload(true)
.maxAgeInSeconds(31_536_000)) // one year
.contentTypeOptions(Customizer.withDefaults()) // nosniff
// frame-ancestors is the modern form; keep the legacy header too.
.frameOptions(FrameOptionsConfig::deny)
.contentSecurityPolicy(csp -> csp.policyDirectives(String.join("; ",
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' data: https://cdn.acme.com",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
"upgrade-insecure-requests")))
.referrerPolicy(referrer -> referrer
.policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
.permissionsPolicyHeader(permissions -> permissions
.policy("camera=(), microphone=(), geolocation=(), payment=(), usb=()"))
.crossOriginOpenerPolicy(coop -> coop
.policy(CrossOriginOpenerPolicyHeaderWriter.CrossOriginOpenerPolicy.SAME_ORIGIN))
// Authenticated pages must not be cached by shared proxies.
.cacheControl(Customizer.withDefaults()));
return http.build();
}For static assets and API responses served outside the security filter chain, add the same headers in
next.config-style platform configuration or at the reverse proxy, or they will be missing on exactly
the responses attackers look at.
HSTS
Strict-Transport-Security tells the browser to refuse plaintext connections to your domain for
max-age seconds. It closes the window where a user typing example.com makes one HTTP request that
an attacker on the network can intercept — including the cookie, if Secure was not set.
Roll it out gradually: start at max-age=300, confirm nothing breaks, raise to a day, then a week,
then a year. Only then consider preload.
Preloading is effectively permanent. Every browser ships the list, and removal takes months to reach
users. Before submitting, verify that every subdomain — including internal tools, legacy hosts and
anything a partner uses — can serve HTTPS, because includeSubDomains will break the ones that
cannot.
Rolling out CSP
// Phase 1: observe only. Violations are reported, nothing is blocked.
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; report-uri /csp-report")
.reportOnly())@PostMapping(value = "/csp-report", consumes = "application/csp-report")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void report(@RequestBody CspReport report) {
// Expect noise from browser extensions injecting scripts. Filter by
// blocked-uri before treating a report as a real violation.
log.info("csp violation: directive={} blocked={} document={}",
report.violatedDirective(), report.blockedUri(), report.documentUri());
metrics.counter("csp.violation", "directive", report.violatedDirective()).increment();
}Run report-only for a week, look at what genuinely breaks, fix those, then enforce. Deploying a strict CSP without this step reliably breaks analytics, embedded widgets or a stylesheet nobody remembered, and the rollback teaches the team that CSP is not worth it.
report-uri is deprecated in favour of report-to alongside a Reporting-Endpoints header, but
support for the newer mechanism is uneven enough that sending both remains the pragmatic choice.
Nonces
script-src 'self' blocks every inline <script>, including the small bootstrap blocks most
templating engines emit. The common reaction is to add 'unsafe-inline', which returns the policy to
approximately no protection against injected script at all — the exact thing CSP was added for. A
nonce is the way out: generate a random value per response, put it in the header and on each
legitimate inline script, and the browser runs those and nothing else.
String nonce = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes(16));
request.setAttribute("cspNonce", nonce); // templates read this for script tags
response.setHeader("Content-Security-Policy", String.join("; ",
"default-src 'self'",
"script-src 'self' 'nonce-%s' 'strict-dynamic'".formatted(nonce),
"object-src 'none'",
"base-uri 'self'"));The nonce has to be unpredictable and regenerated per response; a fixed value in configuration is
'unsafe-inline' with extra steps. Pairing it with 'strict-dynamic' is what makes the approach
practical — scripts loaded by an already-trusted script inherit that trust, so bundlers that inject
their own tags keep working without you enumerating every URL they might reach for.
Cache headers on private pages
@GetMapping("/api/v1/me")
public ResponseEntity<Profile> me(@AuthenticationPrincipal Jwt jwt) {
return ResponseEntity.ok()
// Without no-store, a shared proxy or a CDN can cache one user's
// response and serve it to another.
.cacheControl(CacheControl.noStore().mustRevalidate())
.header(HttpHeaders.PRAGMA, "no-cache")
.body(profileService.forUser(jwt.getSubject()));
}Spring Security sets these by default on secured endpoints, which is one of the more valuable defaults it provides. If you override cache headers globally for performance, make sure authenticated responses are excluded.
Verifying
curl -sI https://app.acme.com | grep -iE 'strict-transport|content-security|x-content-type|referrer|permissions'Check headers in CI so a configuration change cannot silently remove one:
@Test
void sendsExpectedSecurityHeaders() throws Exception {
mvc.perform(get("/"))
.andExpect(header().string("X-Content-Type-Options", "nosniff"))
.andExpect(header().string("X-Frame-Options", "DENY"))
.andExpect(header().exists("Content-Security-Policy"))
.andExpect(header().string("Strict-Transport-Security",
containsString("max-age=31536000")));
}Free scanners such as securityheaders.com and Mozilla Observatory give a quick external grade, which is a useful sanity check but not a substitute for the test above — external scans do not run on every deploy.
What to take away
Set the whole list; most are one line and prevent a real attack class. Roll HSTS out gradually and treat preload as permanent. Deploy CSP in report-only mode first and fix violations before enforcing. Then assert the headers in a test so nobody removes one by accident.
Frequently Asked Questions
Which headers matter most?
Is HSTS preload safe to enable?
X-Frame-Options or CSP frame-ancestors?
Related tutorials
- CORS ConfigurationUnderstanding the same-origin policy, what preflight actually checks, configuring CORS in Spring correctly, and why allowedOrigins star with credentials is refused.
- SSL/TLS & HTTPS in Spring BootConfiguring TLS properly: the handshake, keystores and PKCS12, HTTP to HTTPS redirect, mutual TLS for service-to-service, cipher policy, and where to terminate.
- CSRF ProtectionHow CSRF works, how Spring CsrfFilter stops it, SameSite cookies as a second layer, the double-submit pattern for SPAs, and exactly when disabling CSRF is correct.
- SQL Injection PreventionHow SQL injection actually works, why parameterised queries stop it, the JPA and JdbcTemplate patterns that are safe, the ones that are not, and how to test for it.