Security Headers: CSP, HSTS, Clickjacking Protection
Why Security Headers Matter
Security headers tell browsers how to behave when handling your content. They stop entire classes of attacks — XSS, clickjacking, protocol downgrade, information leakage — with a few lines of configuration. They cost nothing at runtime and are one of the highest-value-per-effort security improvements available.
Spring Security’s Default Headers
Spring Security adds a set of secure headers by default. You do not need any explicit configuration to get them:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
Strict-Transport-Security: max-age=31536000; includeSubDomains
Check with curl:
curl -I https://yourapp.com/login
The Headers Explained
X-Content-Type-Options: nosniff
Prevents browsers from MIME-type sniffing. Without this, a browser might execute a JavaScript file disguised as an image.
http.headers(headers -> headers
.contentTypeOptions(Customizer.withDefaults()) // enabled by default
);
X-Frame-Options
Prevents your pages from being embedded in iframes — the core defence against clickjacking attacks.
http.headers(headers -> headers
.frameOptions(frame -> frame.deny()) // blocks all framing (default)
// .frameOptions(frame -> frame.sameOrigin()) // allow framing from same origin only
);
X-XSS-Protection: 0
This header was originally intended to activate the browser’s built-in XSS filter. Modern browsers have removed this filter, and the header can actually introduce vulnerabilities in some configurations. Spring Security defaults to 0 (disabled). Rely on CSP instead.
Cache-Control
Prevents browsers from caching authenticated responses:
http.headers(headers -> headers
.cacheControl(Customizer.withDefaults()) // enabled by default
);
Override for specific responses (e.g. static assets that should be cached):
@GetMapping("/static/logo.png")
public ResponseEntity<byte[]> logo() {
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic())
.body(logoBytes);
}
HTTP Strict Transport Security (HSTS)
HSTS tells browsers to always use HTTPS for your domain — even if the user types http://. Once a browser has seen the HSTS header, it upgrades HTTP requests to HTTPS before they leave the browser, making protocol downgrade attacks impossible.
http.headers(headers -> headers
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000) // one year
.includeSubDomains(true) // apply to all subdomains
.preload(true) // request inclusion in browser preload list
)
);
Spring Security only sends HSTS over HTTPS connections (sending it over HTTP has no effect and could block access if the certificate is misconfigured). This is the correct default behavior.
HSTS Preloading
After deploying HSTS with max-age=31536000; includeSubDomains; preload, submit your domain to the browser preload list at hstspreload.org. Browsers that have never visited your site will use HTTPS from the first connection.
Warning: HSTS preloading is difficult to reverse — you are committing to HTTPS for all subdomains indefinitely. Only enable preload when your entire domain infrastructure is HTTPS-ready.
Content Security Policy (CSP)
CSP is the most powerful XSS defence. It tells the browser which sources are allowed for scripts, styles, images, and other resources. A script injected by an attacker is blocked if it violates the policy.
Basic CSP
http.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' https://fonts.googleapis.com; " +
"img-src 'self' data: https:; " +
"font-src 'self' https://fonts.gstatic.com; " +
"frame-ancestors 'none'; " +
"form-action 'self'; " +
"upgrade-insecure-requests"
)
)
);
CSP Directives Reference
| Directive | Controls |
|---|---|
default-src | Fallback for all fetch directives not explicitly set |
script-src | JavaScript sources |
style-src | CSS sources |
img-src | Image sources |
font-src | Font sources |
connect-src | AJAX, WebSocket, EventSource |
frame-src | Iframe sources |
frame-ancestors | Which pages may embed this page (replaces X-Frame-Options) |
form-action | Where forms may submit |
upgrade-insecure-requests | Upgrade HTTP subresource requests to HTTPS |
block-all-mixed-content | Block all mixed content (HTTP on HTTPS page) |
CSP in Report-Only Mode
Roll out CSP without breaking your application — start in report-only mode:
http.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; report-uri /csp-report")
.reportOnly() // violations are reported but not blocked
)
);
@RestController
public class CspReportController {
@PostMapping("/csp-report")
public ResponseEntity<Void> cspReport(@RequestBody Map<String, Object> report) {
log.warn("CSP violation: {}", report);
return ResponseEntity.ok().build();
}
}
Monitor reports for a few weeks, fix legitimate violations (inline scripts that need 'unsafe-inline' or a nonce), then switch to enforcing mode.
Nonces for Inline Scripts
Avoid 'unsafe-inline' in script-src — it defeats CSP. Use nonces instead:
@Bean
public CspNonceFilter cspNonceFilter() {
return new CspNonceFilter();
}
public class CspNonceFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
byte[] bytes = new byte[16];
new SecureRandom().nextBytes(bytes);
String nonce = Base64.getEncoder().encodeToString(bytes);
request.setAttribute("cspNonce", nonce);
chain.doFilter(request, response);
}
}
In your security config, include the nonce in the CSP header dynamically. In Thymeleaf:
<script th:nonce="${cspNonce}">
// This inline script is allowed — nonce matches the CSP header
console.log("App started");
</script>
Referrer-Policy
Controls how much referrer information is sent with requests:
http.headers(headers -> headers
.referrerPolicy(referrer -> referrer
.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
)
);
| Policy | Behavior |
|---|---|
no-referrer | Never send referrer |
no-referrer-when-downgrade | No referrer from HTTPS to HTTP |
same-origin | Send referrer only for same-origin requests |
strict-origin | Send origin only (no path), no referrer HTTPS→HTTP |
strict-origin-when-cross-origin | Full URL for same-origin; origin only for cross-origin |
strict-origin-when-cross-origin is the recommended default — it preserves analytics for same-origin links while protecting sensitive URL paths from leaking to external sites.
Permissions-Policy
Restricts browser features available to your page and embedded content:
http.headers(headers -> headers
.permissionsPolicy(permissions -> permissions
.policy("camera=(), microphone=(), geolocation=(), payment=()")
)
);
Explicitly deny features your application does not use — this limits what a successful XSS can access.
Full Header Configuration
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.headers(headers -> headers
.contentTypeOptions(Customizer.withDefaults())
.frameOptions(frame -> frame.deny())
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000)
.includeSubDomains(true)
)
.referrerPolicy(referrer -> referrer
.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
)
.permissionsPolicy(permissions -> permissions
.policy("camera=(), microphone=(), geolocation=()")
)
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' https://fonts.googleapis.com; " +
"img-src 'self' data:; " +
"font-src 'self' https://fonts.gstatic.com; " +
"frame-ancestors 'none'; " +
"form-action 'self'"
)
)
);
return http.build();
}
Disabling Default Headers (When Necessary)
For APIs that return non-HTML responses (JSON, binary), cache-control headers may interfere with intended caching. Disable selectively:
http.headers(headers -> headers
.cacheControl(cache -> cache.disable()) // allow API responses to be cached
);
To start from scratch with no default headers:
http.headers(headers -> headers.defaultsDisabled()
.frameOptions(frame -> frame.deny())
.contentTypeOptions(Customizer.withDefaults())
.httpStrictTransportSecurity(Customizer.withDefaults())
);
Testing Security Headers
Verify headers with Mozilla Observatory (observatory.mozilla.org) or Security Headers (securityheaders.com). Locally:
curl -I http://localhost:8080/login | grep -E "(X-Content|X-Frame|Strict-Transport|Content-Security)"
Key Takeaways
- Spring Security adds a solid default set of headers automatically — no configuration needed for
X-Content-Type-Options,X-Frame-Options,Cache-Control, and HSTS - CSP is the strongest XSS defence — start in report-only mode, fix violations, then enforce
- Use nonces instead of
'unsafe-inline'inscript-srcto permit specific inline scripts without weakening CSP Referrer-Policy: strict-origin-when-cross-originprotects sensitive URL paths from leaking to third partiesPermissions-Policyrestricts browser API access — deny everything your app does not use- Test with Mozilla Observatory before going to production
Next: Multi-Factor Authentication (TOTP and WebAuthn) — add a second factor with time-based OTPs and passkeys.