Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Beginner5 min readUpdated
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; keep X-Frame-Options for old clients.
  • nosniff stops the browser guessing a content type and executing your uploads.
  • Permissions-Policy disables device APIs your site never uses.

The set worth sending

HeaderValuePrevents
Strict-Transport-Securitymax-age=31536000; includeSubDomains; preloadHTTPS downgrade
Content-Security-PolicyNonce-based, see belowXSS impact, data exfiltration
X-Content-Type-OptionsnosniffMIME sniffing
X-Frame-OptionsDENYClickjacking (legacy clients)
Referrer-Policystrict-origin-when-cross-originURL leakage to third parties
Permissions-Policycamera=(), microphone=(), geolocation=()Unwanted device access
Cache-Controlno-store on authenticated pagesCached private data
Cross-Origin-Opener-Policysame-originCross-window attacks

Spring configuration

SecurityHeadersConfig.java
@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.

HSTS closes the first-request gap. Preloading closes it even for a browser that has never visited before.

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

CspRollout.java
// Phase 1: observe only. Violations are reported, nothing is blocked.
.contentSecurityPolicy(csp -> csp
    .policyDirectives("default-src 'self'; report-uri /csp-report")
    .reportOnly())
CspReportController.java
@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.

CspNonceFilter.java
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

CacheHeaders.java
@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

terminal
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:

SecurityHeadersTest.java
@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?
Strict-Transport-Security and Content-Security-Policy do the most work. HSTS eliminates downgrade attacks and cookie theft over plaintext; CSP limits the damage from any injected content. X-Content-Type-Options and frame-ancestors are one line each and worth setting immediately.
Is HSTS preload safe to enable?
It is effective and hard to reverse. Once your domain is in the browser preload list, every browser refuses plaintext connections to it and all subdomains, and removal takes months to propagate. Only submit when you are certain every subdomain — including internal tools and legacy hosts — can serve HTTPS.
X-Frame-Options or CSP frame-ancestors?
frame-ancestors is the modern replacement and is more expressive, supporting multiple origins. Send both for now: some older clients and scanners still only understand X-Frame-Options, and sending both costs nothing.

Related tutorials