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

DirectiveControls
default-srcFallback for all fetch directives not explicitly set
script-srcJavaScript sources
style-srcCSS sources
img-srcImage sources
font-srcFont sources
connect-srcAJAX, WebSocket, EventSource
frame-srcIframe sources
frame-ancestorsWhich pages may embed this page (replaces X-Frame-Options)
form-actionWhere forms may submit
upgrade-insecure-requestsUpgrade HTTP subresource requests to HTTPS
block-all-mixed-contentBlock 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)
    )
);
PolicyBehavior
no-referrerNever send referrer
no-referrer-when-downgradeNo referrer from HTTPS to HTTP
same-originSend referrer only for same-origin requests
strict-originSend origin only (no path), no referrer HTTPS→HTTP
strict-origin-when-cross-originFull 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' in script-src to permit specific inline scripts without weakening CSP
  • Referrer-Policy: strict-origin-when-cross-origin protects sensitive URL paths from leaking to third parties
  • Permissions-Policy restricts 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.