---
key: "security-headers"
title: "Configure HTTP security headers from a tested baseline"
url: "https://softwaresecurity.com/domains/appsec/browser-security/security-headers/"
api: "https://softwaresecurity.com/api/v1/articles/security-headers.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-1021","CWE-319","CWE-614","CWE-1004","A02:2025","A04:2025","A06:2025","API8:2023","V3","CAPEC-103","CAPEC-102","WSTG-CONF-07","WSTG-CLNT-09"]
tags: ["Browser security","TLS","Secure defaults and frameworks"]
stages: ["build"]
roles: ["developer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Configure HTTP security headers from a tested baseline

Send the response headers that make browsers enforce HTTPS, run only your own scripts, refuse hostile framing and keep cookies on one host, then prove they appear on every kind of response.

Canonical URL: https://softwaresecurity.com/domains/appsec/browser-security/security-headers/

## In short

Send `Strict-Transport-Security: max-age=63072000; includeSubDomains` on HTTPS responses. On HTML, send a nonce- or hash-based `Content-Security-Policy` with `object-src 'none'`, `base-uri 'none'` and `frame-ancestors 'none'`. Send `X-Content-Type-Options: nosniff` everywhere, `Referrer-Policy: strict-origin-when-cross-origin`, a `Permissions-Policy` that disables unused features and `Cross-Origin-Opener-Policy: same-origin`. Name session cookies `__Host-` with `Secure`, `HttpOnly` and `SameSite=Lax`. Start CSP in report-only mode, and test error pages and redirects too.

- Generate a fresh CSP nonce of at least 128 bits for every response, and never cache a page that carries one.
- Ramp HSTS `max-age` up in stages, and treat preload as a one-way door.
- Use `frame-ancestors` against clickjacking; `X-Frame-Options` is only a fallback.
- Don't send `Referrer-Policy: no-referrer` if your CSRF check reads the `Origin` header on form posts.
- Check 404s, redirects and static files: proxies and default error handlers often drop headers.
- Send `X-XSS-Protection: 0` or nothing; current browsers no longer have the filter it controlled.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Strict-Transport-Security | `max-age=63072000; includeSubDomains` | HTTPS responses only, after a staged ramp. ASVS 5.0 sets a one-year minimum. Add preload only after reading the hstspreload.org requirements. | RFC 6797; ASVS 5.0 3.4.1 |
| Content-Security-Policy (HTML) | `script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; report-uri /csp-reports; report-to csp` | New 128-bit nonce per response, or `'sha256-...'` hashes for static pages. Deploy as `Content-Security-Policy-Report-Only` first. | CSP Level 3 (W3C WD, 16 Sep 2026) 7.1, 8.5 |
| Reporting-Endpoints | `csp="https://app.example.com/csp-reports"` | Defines the endpoint that `report-to` names. Keep `report-uri` as a fallback; Firefox added `report-to` only in version 149. | MDN browser-compat-data 8.1.5 |
| X-Frame-Options | `DENY` | Fallback for old browsers. Ignored when an enforced CSP contains `frame-ancestors`. | CSP Level 3 6.4.2.2 |
| X-Content-Type-Options | `nosniff` | Every response. Requires a correct `Content-Type` on scripts and stylesheets. | ASVS 5.0 3.4.4 |
| Referrer-Policy | `strict-origin-when-cross-origin` | Matches the current browser default; set it explicitly anyway. | ASVS 5.0 3.4.5 |
| Permissions-Policy | `camera=(), microphone=(), geolocation=()` | List every powerful feature the site doesn't use. Applied by Chromium-based browsers only. | OWASP HTTP Headers Cheat Sheet |
| Cross-Origin-Opener-Policy | `same-origin` | `same-origin-allow-popups` on pages that open OAuth or payment popups which report back to the opener. | ASVS 5.0 3.4.8 |
| Cross-Origin-Resource-Policy | `same-origin` | `same-site` for assets shared across your subdomains; `cross-origin` for files other sites embed. | OWASP HTTP Headers Cheat Sheet |
| Cross-Origin-Embedder-Policy | Not set by default | Only when the page needs `crossOriginIsolated`: `require-corp`, or `credentialless` (not supported in Safari). | MDN browser-compat-data 8.1.5 |
| Session cookie | `__Host-sid=...; Path=/; Secure; HttpOnly; SameSite=Lax` | No `Domain` attribute. Use `SameSite=Strict` if users can accept arriving logged out from links on other sites. | draft-ietf-httpbis-rfc6265bis-22 4.1.3; ASVS 5.0 3.3 |
| Cache-Control (authenticated responses) | `no-store` | Keeps personal data out of browser and shared caches. | RFC 9111 5.2.2.5 |
| X-XSS-Protection | `0`, or omit the header | Chrome and Safari removed the filter; Firefox never had it. | OWASP HTTP Headers Cheat Sheet |

## Verify it

1. Fetch a page, a 404, a redirect and a static file through the production CDN and load balancer with `curl -sI`, and confirm each carries the expected headers.
2. Load the same page twice: the CSP nonce must differ between responses and match the nonce attribute on every script element.
3. In a test environment, inject a script element without a nonce and frame the page from another origin; the browser console must report both as blocked.
4. Confirm that plain-HTTP requests get a 301 without `Strict-Transport-Security` and HTTPS responses carry it, which proves the application sees the original scheme behind the proxy.
5. Run Mozilla's HTTP Observatory and Google's CSP Evaluator against staging, and check the domain on hstspreload.org before sending `preload`.
6. Add an integration test (supertest, MockMvc or Django's test client) that asserts the exact header values, so a refactor cannot drop them silently.

## What you will learn

- Choose headers per response type and explain what each changes in the browser.
- Deploy a nonce- or hash-based CSP from report-only to enforcement.
- Configure HSTS, framing, cookie and cross-origin headers in a framework and a proxy.
- Verify headers on error pages, redirects and static files with repeatable tests.

Audience: Developers and reviewers who own the HTTP responses of a web application

Before you start:

- Read raw HTTP request and response headers.
- Know where your framework, reverse proxy or CDN adds response headers.
- Understand XSS and CSRF at a basic level.

Time: About 47 minutes: 15 to read, 10 on the code and 22 for the exercise

## Send each header where the browser uses it

Security headers are instructions to the browser, and each one applies only to the response that carries it. They decide whether the browser may fall back to plain HTTP, which scripts may run, who may frame the page and how cookies travel. Decide the set per response type, then make every layer that can produce a response apply it: the application, its error handlers, the reverse proxy and the CDN.

The table assumes a typical web application; exact values are in the defaults block. API-only services need fewer headers, but HSTS, `nosniff` and CORP still apply to them.

| Response | Send | Why |
| --- | --- | --- |
| HTML documents, including error pages | HSTS, CSP with `frame-ancestors`, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, `Permissions-Policy`, COOP | These responses run script, render forms and can be framed. |
| JSON and other API responses | HSTS, `nosniff`, CORP, `Cache-Control: no-store` when the data is personal | Stops a response being sniffed as HTML or read by another site's no-cors request. |
| Scripts, stylesheets, images and fonts | HSTS, `nosniff` with a correct `Content-Type`, CORP | `nosniff` makes the browser refuse scripts and styles served with the wrong type. |
| User-uploaded files | `nosniff`, `Content-Disposition: attachment`, CSP `sandbox`, ideally a separate origin | Keeps an upload from rendering as an active page on your origin. |
| HTTP-to-HTTPS redirects | No HSTS on the HTTP hop; the full set on the HTTPS response | Browsers ignore HSTS received over plain HTTP. |

## Turn on HSTS in stages

`Strict-Transport-Security` tells the browser to use only HTTPS for the host for `max-age` seconds and, with `includeSubDomains`, for every subdomain. Once it holds the policy, the browser rewrites `http://` links before sending anything and gives the user no way to click through a certificate error. That defeats SSL stripping, where an on-path attacker keeps the victim on plain HTTP, and stops cookies from being read or planted over HTTP.

Browsers ignore the header on plain-HTTP responses, so answer HTTP with a 301 to HTTPS and send the policy on the HTTPS response. Send it from the registrable domain (`example.com`) as well as from `www`: a policy learned only from `www.example.com` never covers `api.example.com`. Behind a TLS-terminating proxy, Django, Spring Security and ASP.NET Core add the header only to requests they recognise as HTTPS, so configure trusted forwarded-proto handling or set the header at the proxy. Ramp the value up as hstspreload.org recommends:

- Confirm every subdomain, internal ones included, serves valid HTTPS.
- Send `max-age=300; includeSubDomains`, then `604800` (one week), then `2592000` (one month), waiting out each period and watching for breakage.
- Settle on at least `31536000` (one year), the ASVS 5.0 floor; `63072000` (two years) is common.
- `max-age=0` clears the policy, but only in browsers that can still load the site over HTTPS to receive it.

## Treat HSTS preload as a one-way door

Preload removes the first-visit gap by shipping your domain in the browser's built-in list. It requires a `max-age` of at least one year, `includeSubDomains`, the `preload` token and HTTPS on every subdomain, and removal takes months to reach users. hstspreload.org now says HSTS is recommended but preloading is not, because Chrome and Safari already try HTTPS first; Google announced that Chrome 154 (October 2026) asks before the first visit to any public HTTP-only site.

ASVS 5.0 still asks for preload at level 3 (requirement 3.7.4). Weigh the remaining gap, an active attacker defeating the HTTPS-first upgrade on a first visit, against the commitment never to serve HTTP on any subdomain again. Don't let a framework default or a deploy checker add the `preload` token for you.

## Use a nonce- or hash-based CSP, not a host allowlist

A host allowlist such as `script-src 'self' https://cdn.example.net` trusts every script on those hosts, including JSONP endpoints and old library versions an attacker can load, which is why allowlist policies are often bypassable. A strict policy marks the scripts you wrote instead: each script element carries a nonce that matches the header, or the policy lists the SHA-256 hash of each inline script. Injected markup has neither, so it does not run. `'strict-dynamic'` lets a trusted script load its dependencies through `createElement`; parser-inserted scripts, inline event handlers and `javascript:` URLs stay blocked. If your code builds script URLs from data, audit it, because `'strict-dynamic'` trusts whatever those scripts load.

The nonce must come from a cryptographically secure generator, carry at least 128 bits and be new for every response; CSP Level 3 makes uniqueness a MUST. Never add nonces by rewriting every script tag in middleware, which also blesses injected ones, and never let a cache serve one user's nonce to another. `object-src 'none'` removes plugin content, and `base-uri 'none'` stops an injected `<base>` element from re-pointing relative script URLs. Put `frame-ancestors` in the same header; it has no effect in a `<meta>` tag. The lab at /labs/csp-policy/ has you build such a policy.

```http
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Security-Policy: script-src 'nonce-q7Lk2JmQ0Xc5hV9sYp3RfA==' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; report-uri /csp-reports; report-to csp
Reporting-Endpoints: csp="https://app.example.com/csp-reports"

<script nonce="q7Lk2JmQ0Xc5hV9sYp3RfA==" src="/static/app.js"></script>
<script nonce="q7Lk2JmQ0Xc5hV9sYp3RfA==">initApp();</script>
<!-- An injected <script> or onclick= attribute has no nonce and does not run. -->
```

## Roll CSP out in report-only mode

Send the policy first as `Content-Security-Policy-Report-Only`. The browser reports what the policy would block without blocking it, so you can move inline event handlers into `addEventListener` calls, add nonces or hashes to the remaining inline scripts and replace `eval`. A strict report-only policy can run alongside a looser enforced one. When the reports show only noise, rename the header. If a response carries several enforced policies, the browser applies all of them, so a second policy can only tighten the first.

`report-to` names an endpoint declared in the `Reporting-Endpoints` header, and those reports arrive as `application/reports+json`. The deprecated `report-uri` directive posts `application/csp-report`; keep it as a fallback, since browsers that support `report-to` ignore it. Reports contain page URLs, so rate-limit the endpoint and strip query strings before storing them. Static pages can use hashes instead; hash the exact script text, because any whitespace change alters the hash. Trusted Types (`require-trusted-types-for 'script'`), now in Chrome, Firefox and Safari, is the next step: it restricts DOM sinks such as `innerHTML`.

```bash
# Hash an inline script exactly as it appears between <script> and </script>
printf '%s' "console.log('hi')" | openssl dgst -sha256 -binary | openssl base64
# Output: 1ohZFo3B9w3UOFBbfx6JSomkpkME90iPs1r/qXzvX7Y=
# Header to send first, which reports violations without blocking:
# Content-Security-Policy-Report-Only: script-src 'sha256-1ohZFo3B9w3UOFBbfx6JSomkpkME90iPs1r/qXzvX7Y=' 'strict-dynamic'; object-src 'none'; base-uri 'none'; report-to csp
```

## Block framing with frame-ancestors

Clickjacking loads your page in an invisible frame and lines up one of its buttons under something the victim means to click. CSP `frame-ancestors 'none'` stops every site from framing the response, `'self'` allows only your origin, and an explicit origin allows one partner. When an enforced policy contains `frame-ancestors`, browsers ignore `X-Frame-Options`, so keep `X-Frame-Options: DENY` only as a fallback for old browsers. Its `ALLOW-FROM` value is obsolete, and modern browsers ignore a header that uses it.

Apply the policy to every HTML response, including login, consent and error pages; RFC 9700 requires OAuth authorization servers to prevent clickjacking on those pages. `SameSite=Lax` or `Strict` session cookies add a second layer, because a cross-site frame then loads logged out. Framing headers cannot stop DoubleClickjacking, which tricks a double-click into a top-level window, so make users review and confirm high-risk actions and authorize them on the server.

```http
# Every HTML response (the rest of the CSP omitted)
Content-Security-Policy: ...; frame-ancestors 'none'
X-Frame-Options: DENY

# Only /widget, which partner.example.org may embed
Content-Security-Policy: ...; frame-ancestors https://partner.example.org
```

## Stop MIME sniffing, trim referrers and switch off unused features

`X-Content-Type-Options: nosniff` makes the browser trust your `Content-Type`. It blocks a script or stylesheet served with the wrong type and stops a text or JSON response from being rendered as HTML, which matters for uploads and for API errors that echo input. Send it on every response, then fix any asset whose `Content-Type` is wrong.

`Referrer-Policy: strict-origin-when-cross-origin` sends only your origin to other sites and nothing on HTTPS-to-HTTP requests. Browsers have used it as the default since their 2020 and 2021 releases, but set it explicitly. `no-referrer` leaks less and has a side effect: browsers then send `Origin: null` on form posts, even to your own site, which fails CSRF checks that compare the `Origin` header (/domains/appsec/browser-security/csrf-origin-defense/). Django's CSRF middleware, for example, rejects those posts with 403.

`Permissions-Policy: camera=(), microphone=(), geolocation=()` disables those APIs for the page and every frame inside it, so injected script or a compromised third-party iframe cannot ask for them. List each powerful feature you don't use. Only Chromium-based browsers apply the header; Firefox and Safari support just the iframe `allow` attribute.

## Isolate windows and resources with COOP, CORP and COEP

`Cross-Origin-Opener-Policy: same-origin` keeps your page out of any browsing context group shared with cross-origin documents, so a cross-origin window that opened it, or that it opens, gets no usable reference to it. That blocks tabnabbing and window-based cross-site leaks. It also cuts `window.opener` for cross-origin popups, which breaks sign-in and payment popups that post a result back; use `same-origin-allow-popups` on the pages that open them.

`Cross-Origin-Resource-Policy` tells the browser who may load a resource through no-cors requests such as img and script tags, which keeps private images and JSON out of another site's renderer process. Use `same-origin` for private responses, `same-site` for assets shared across your subdomains and `cross-origin` for files you publish for embedding. `Cross-Origin-Embedder-Policy` works the other way: it limits what your page may load. Together with `COOP: same-origin` it makes the page cross-origin isolated, which unlocks `SharedArrayBuffer` and high-resolution timers. Set `require-corp`, or `credentialless` outside Safari, only when you need those features and every third-party resource has been checked.

```http
# HTML pages (same-origin-allow-popups if they open OAuth or payment popups)
Cross-Origin-Opener-Policy: same-origin

# Private API responses and assets
Cross-Origin-Resource-Policy: same-origin

# Only if the page needs crossOriginIsolated
Cross-Origin-Embedder-Policy: require-corp
```

## Lock session cookies to one host with \_\_Host-

`Secure` keeps a cookie off plain HTTP, `HttpOnly` hides it from `document.cookie`, and `SameSite` controls when it travels on cross-site requests. `Lax` is the practical default for session cookies; `Strict` also withholds the cookie when a link from another site opens your app; `None` sends it everywhere and must be paired with `Secure`. Only Chromium treats a cookie without `SameSite` as `Lax`, so always set the attribute. `HttpOnly` stops injected script from reading the cookie, not from sending requests that carry it.

The `__Host-` name prefix makes the browser enforce the rest: it accepts the cookie only if it is `Secure`, set from an HTTPS page, has `Path=/` and has no `Domain` attribute. The cookie therefore stays on the exact host that set it, and a sibling subdomain cannot overwrite it. Use `__Secure-` for a cookie you deliberately share across subdomains. Chrome and Firefox also accept `__Host-Http-`, which additionally requires `HttpOnly`; Safari does not yet enforce it.

```http
# Accepted: host-only, HTTPS-only, hidden from document.cookie
Set-Cookie: __Host-sid=8vJ2lQ3mR0aT6yNc; Path=/; Secure; HttpOnly; SameSite=Lax

# Rejected by browsers that implement cookie prefixes
Set-Cookie: __Host-sid=8vJ2lQ3mR0aT6yNc; Path=/; Secure; Domain=example.com
Set-Cookie: __Host-sid=8vJ2lQ3mR0aT6yNc; Path=/app; Secure
Set-Cookie: __Host-sid=8vJ2lQ3mR0aT6yNc; Path=/
```

## Express: helmet plus a per-response nonce

helmet 8 sets most of the baseline by default, including HSTS with a one-year `max-age`, `nosniff`, COOP and CORP `same-origin`, `X-Frame-Options: SAMEORIGIN` and `X-XSS-Protection: 0`, and it removes `X-Powered-By`. The directives below are merged into helmet's default CSP, replacing its `'self'` script allowlist with a nonce policy. helmet has no `Permissions-Policy` option, so set that header yourself. Express 5's built-in 404 page sends its own stricter CSP, `default-src 'none'`.

```js
import crypto from 'node:crypto';
import express from 'express';
import helmet from 'helmet';

export const app = express();

// A fresh 128-bit nonce for every response.
app.use((req, res, next) => {
  res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
  next();
});

app.use(helmet({
  contentSecurityPolicy: {
    // Merged into helmet's default policy (default-src 'self', object-src 'none', ...).
    directives: {
      'script-src': [(req, res) => `'nonce-${res.locals.cspNonce}'`, "'strict-dynamic'"],
      'base-uri': ["'none'"],
      'frame-ancestors': ["'none'"],
      'report-uri': ['/csp-reports'],
      'report-to': ['csp'],
    },
  },
  strictTransportSecurity: { maxAge: 63072000, includeSubDomains: true },
  xFrameOptions: { action: 'deny' },
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));

// helmet has no option for these two headers.
app.use((req, res, next) => {
  res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
  res.setHeader('Reporting-Endpoints', 'csp="https://app.example.com/csp-reports"');
  next();
});

app.get('/', (req, res) => {
  res.cookie('__Host-sid', crypto.randomBytes(32).toString('base64url'), {
    secure: true, httpOnly: true, sameSite: 'lax', path: '/',
  });
  res.send(`<!doctype html><title>Home</title>
<script nonce="${res.locals.cspNonce}" src="/app.js"></script>`);
});
```

## Spring Security: headers() plus a nonce filter

Spring Security 7 already sends `nosniff`, `X-Frame-Options: DENY`, `X-XSS-Protection: 0`, headers that disable caching and, on HTTPS requests only, HSTS with a one-year `max-age`. It sends no CSP and has no nonce support, so generate the nonce in a filter that runs before `HeaderWriterFilter` and read the request attribute in templates, for example `th:attr="nonce=${cspNonce}"` in Thymeleaf. Behind a load balancer, set `server.forward-headers-strategy=native` so the application sees the original HTTPS scheme; otherwise HSTS never appears.

```java
package com.example.headers;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.Base64;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.HeaderWriterFilter;
import org.springframework.security.web.header.writers.CrossOriginOpenerPolicyHeaderWriter.CrossOriginOpenerPolicy;
import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy;
import org.springframework.web.filter.OncePerRequestFilter;

@Configuration
class SecurityConfig {

  @Bean
  SecurityFilterChain web(HttpSecurity http) throws Exception {
    http
      .authorizeHttpRequests(a -> a.anyRequest().permitAll())
      .addFilterBefore(new CspNonceFilter(), HeaderWriterFilter.class)
      .headers(h -> h
        .httpStrictTransportSecurity(hsts -> hsts.maxAgeInSeconds(63072000).includeSubDomains(true))
        .frameOptions(f -> f.deny())
        .referrerPolicy(r -> r.policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
        .permissionsPolicyHeader(p -> p.policy("camera=(), microphone=(), geolocation=()"))
        .crossOriginOpenerPolicy(c -> c.policy(CrossOriginOpenerPolicy.SAME_ORIGIN))
        .addHeaderWriter((req, res) -> res.setHeader("Content-Security-Policy",
            "script-src 'nonce-" + req.getAttribute("cspNonce") + "' 'strict-dynamic'; "
            + "object-src 'none'; base-uri 'none'; frame-ancestors 'none'")));
    return http.build();
  }

  static final class CspNonceFilter extends OncePerRequestFilter {
    private static final SecureRandom RANDOM = new SecureRandom();

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
        throws ServletException, IOException {
      byte[] bytes = new byte[16];
      RANDOM.nextBytes(bytes);
      req.setAttribute("cspNonce", Base64.getEncoder().encodeToString(bytes)); // read in templates
      chain.doFilter(req, res);
    }
  }
}
```

## ASP.NET Core: UseHsts plus one header middleware

`UseHsts` adds the header only to HTTPS requests and skips `localhost`, `127.0.0.1` and `[::1]`. Its default `max-age` is 30 days, so set the value explicitly, and keep it out of development because browsers cache the policy. ASP.NET Core has no built-in middleware for the other headers, so add them in one middleware early in the pipeline. Behind a proxy, `UseForwardedHeaders` must run first, with the proxy's address trusted, so `Request.IsHttps` reflects the original scheme.

```csharp
using System.Security.Cryptography;
using Microsoft.AspNetCore.HttpOverrides;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHsts(o =>
{
    o.MaxAge = TimeSpan.FromDays(730);
    o.IncludeSubDomains = true;
    o.Preload = false; // opt in deliberately
});

var app = builder.Build();

// Behind a TLS-terminating proxy, Request.IsHttps must reflect the original scheme.
app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedProto });
if (!app.Environment.IsDevelopment())
{
    app.UseHsts(); // HTTPS requests only; skips localhost, 127.0.0.1 and [::1]
}
app.UseHttpsRedirection();

app.Use(async (context, next) =>
{
    var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
    context.Items["CspNonce"] = nonce; // read it in Razor: nonce="@Context.Items["CspNonce"]"
    var headers = context.Response.Headers;
    headers.ContentSecurityPolicy =
        $"script-src 'nonce-{nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'";
    headers.XContentTypeOptions = "nosniff";
    headers.XFrameOptions = "DENY";
    headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
    headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()";
    headers["Cross-Origin-Opener-Policy"] = "same-origin";
    await next();
});
```

## Django: SecurityMiddleware settings and built-in CSP

With `SecurityMiddleware` enabled, as new projects have it, Django already sends `nosniff`, `Referrer-Policy: same-origin` and `COOP: same-origin`, and `XFrameOptionsMiddleware` sends `X-Frame-Options: DENY`. HSTS and secure cookies stay off until you enable them, and `SecurityMiddleware` adds HSTS only to requests it considers secure. Since Django 6.0, `ContentSecurityPolicyMiddleware` builds the header from `SECURE_CSP` and swaps `CSP.NONCE` for a fresh 128-bit nonce, which the csp context processor exposes as `{{ csp_nonce }}`. The nonce is created lazily, so a page that never reads it gets a policy without one. Django does not send `Reporting-Endpoints`; add it at the proxy. Run `manage.py check --deploy` before each release.

```python
from django.utils.csp import CSP

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.middleware.csp.ContentSecurityPolicyMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

# Only if a trusted proxy terminates TLS and always sets this header.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 63072000            # 2 years
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = False               # opt in deliberately
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin"
X_FRAME_OPTIONS = "DENY"
SECURE_CSP = {
    "script-src": [CSP.NONCE, CSP.STRICT_DYNAMIC],
    "object-src": [CSP.NONE],
    "base-uri": [CSP.NONE],
    "frame-ancestors": [CSP.NONE],
    "report-to": "csp",
}
SESSION_COOKIE_NAME = "__Host-sessionid"
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_NAME = "__Host-csrftoken"
CSRF_COOKIE_SECURE = True
# Templates: add "django.template.context_processors.csp" and write <script nonce="{{ csp_nonce }}">
```

## Next.js: fixed headers in next.config

`headers()` in `next.config` applies fixed headers to every matching route, including static files and the 404 page, so use it for everything except the nonce-based CSP. `poweredByHeader: false` removes `X-Powered-By`.

```js
const securityHeaders = [
  { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains' },
  { key: 'X-Content-Type-Options', value: 'nosniff' },
  { key: 'X-Frame-Options', value: 'DENY' },
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
  { key: 'Cross-Origin-Opener-Policy', value: 'same-origin' },
];

/** @type {import('next').NextConfig} */
export default {
  poweredByHeader: false,
  async headers() {
    return [{ source: '/:path*', headers: securityHeaders }];
  },
};
```

## Next.js: a nonce-based CSP in proxy.ts

Next.js 16 renamed middleware to proxy. The proxy sets the CSP on the request it forwards and on the response; Next.js reads the nonce from the request header and adds it to its framework scripts and page bundles. Nonces need per-request rendering, for example by awaiting `connection()` in the page, which turns off static generation, ISR and CDN caching for those pages. If that cost is too high for some pages, use a hash-based policy for them instead.

```ts
import { NextRequest, NextResponse } from 'next/server';

export function proxy(request: NextRequest) {
  const nonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64');
  const csp = [
    `script-src 'nonce-${nonce}' 'strict-dynamic'`,
    "object-src 'none'",
    "base-uri 'none'",
    "frame-ancestors 'none'",
  ].join('; ');

  // Next.js reads the nonce from the request's CSP header and adds it to its own scripts.
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-nonce', nonce);
  requestHeaders.set('Content-Security-Policy', csp);

  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set('Content-Security-Policy', csp);
  return response;
}

export const config = {
  matcher: [{
    source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
    missing: [
      { type: 'header', key: 'next-router-prefetch' },
      { type: 'header', key: 'purpose', value: 'prefetch' },
    ],
  }],
};
```

## nginx: use always, and watch add_header inheritance

Without the `always` parameter, `add_header` applies only to 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308 responses, so 404 and 500 pages lose every header. A `location` block with any `add_header` of its own discards all `add_header` lines inherited from the server or http level; in nginx 1.29.3 and later, including the 1.30 stable branch, `add_header_inherit merge` keeps them. Generate the nonce-based CSP in the application, and don't set the same header in both places: browsers enforce every CSP they receive and use only the first HSTS header.

```nginx
# Inside the server block that terminates TLS for app.example.com
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;

location /static/ {
    add_header_inherit merge;   # nginx 1.29.3+; without it this block drops every header above
    add_header Cache-Control "public, max-age=31536000, immutable";
}
```

## Pitfalls that silently remove protection

Most header failures are gaps, not wrong values. Check for these before adding anything new.

- Error, redirect and maintenance responses produced by a proxy, CDN or default handler that never runs your middleware.
- A shared cache serving one response's nonce to many users; cache nonce-bearing HTML only privately, or switch those pages to hashes.
- Policies in `<meta>` tags: `frame-ancestors`, `report-uri` and `sandbox` are ignored there, and `X-Frame-Options` does nothing in markup.
- Obsolete headers left in place: `X-Frame-Options: ALLOW-FROM`, `X-XSS-Protection: 1; mode=block`, `Expect-CT` and `Public-Key-Pins`.
- `includeSubDomains` on a domain with an internal HTTP-only host, which becomes unreachable in every browser that has seen the policy.
- `'unsafe-inline'` without a nonce or hash, or `'unsafe-eval'` added to silence a report instead of removing `eval`.
- `Referrer-Policy: no-referrer` combined with Origin-based CSRF checks, which then reject legitimate form posts.

## A real-world failure

The 2018 British Airways breach, which the UK ICO penalized with a 20 million pound fine, was a Magecart attack: injected JavaScript on the payment page skimmed around 400,000 customers' card details to an attacker-controlled domain. A strict `Content-Security-Policy` that restricts `script-src` and `connect-src` is the recognized control that limits this class of skimmer.

## Verify on real responses and keep a regression test

Check deployed headers from outside, through the same CDN and load balancer your users reach, and include a 404, a redirect and a static file. Then test behaviour, not just presence: the nonce changes per response and matches the script tags, a nonce-less test script is blocked, the page refuses to render in a cross-origin frame, and plain HTTP gets a 301 without HSTS. Mozilla's HTTP Observatory, Google's CSP Evaluator and /tools/security-headers/ catch common mistakes, but none knows what your pages need to load.

Encode the expected values in an integration test, or run this script in CI against staging, so a change that drops a header fails the build.

```bash
BASE=${BASE:-https://app.example.com}
for path in / /does-not-exist /app.js; do
  echo "== $path"
  curl -s -o /dev/null -D - "$BASE$path" |
    grep -iE '^(http/|strict-transport-security|content-security-policy|x-frame-options|x-content-type-options|referrer-policy|permissions-policy|cross-origin-(opener|resource)-policy|set-cookie)'
done
```

## Guided practice

Staging responses from `shop.example.test` are below. Work out what each header does in a current browser, then write the corrected set.

### Supplied material

```text
R1  GET https://shop.example.test/  -> 200 text/html
    Strict-Transport-Security: max-age=31536000
    Content-Security-Policy: script-src 'self' 'unsafe-inline' https://cdn.example.net; object-src 'none'
    X-Frame-Options: ALLOW-FROM https://partner.example.org
    X-XSS-Protection: 1; mode=block
    Referrer-Policy: no-referrer
    Set-Cookie: sid=Q2x5c2Vzc2lvbg; Path=/; HttpOnly
R2  GET http://shop.example.test/  -> 301 Location: https://shop.example.test/
    Strict-Transport-Security: max-age=31536000; includeSubDomains
R3  GET https://shop.example.test/no-such-page  -> 404 text/html, no security headers
R4  GET https://shop.example.test/static/app.js  -> 200 Content-Type: text/plain
    X-Content-Type-Options: nosniff
Requirements:
- Only https://partner.example.org may frame /widget; nothing may frame any other page.
- Login and checkout forms are protected by Django's CSRF middleware, which checks the Origin header.
- Every subdomain (www, api, status) already serves valid HTTPS.
```

### 1. Judge the script policy

Decide whether a stored `<script>alert(1)</script>` rendered into R1 would run, and write a replacement policy.

Worked answer: It runs: with no nonce or hash, `'unsafe-inline'` stays in force, and the allowlist trusts every script on `cdn.example.net`. Replace it with `script-src 'nonce-{per-response}' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'`, first as `Content-Security-Policy-Report-Only`.

### 2. Fix framing

Explain what `X-Frame-Options: ALLOW-FROM` does in a current browser, then give the framing headers for `/widget` and for every other page.

Worked answer: Browsers ignore `ALLOW-FROM`, so any site can frame R1. Send `frame-ancestors 'none'` plus `X-Frame-Options: DENY` on normal pages, and `frame-ancestors https://partner.example.org` on `/widget` only.

### 3. Repair HSTS

Evaluate the HSTS headers in R1 and R2 and state the corrected behaviour.

Worked answer: Browsers ignore R2's header, which arrived over plain HTTP; keep the 301 and drop it. R1 lacks `includeSubDomains` although every subdomain serves HTTPS, so after a staged ramp send `max-age=63072000; includeSubDomains` on HTTPS responses from the registrable domain.

### 4. Find the CSRF breakage

Predict what happens to a legitimate checkout form post submitted from R1's page, and fix it.

Worked answer: `Referrer-Policy: no-referrer` sends `Origin: null` on the form post, which Django's CSRF middleware rejects with 403. Switch to `strict-origin-when-cross-origin`.

### 5. Close the remaining gaps

Correct the cookie, R3, R4 and the `X-XSS-Protection` header.

Worked answer: Rename the cookie `__Host-sid` and send it with `Path=/; Secure; HttpOnly; SameSite=Lax`. Make the 404 handler, or the proxy with `always`, send the same headers as R1. Serve `app.js` as `text/javascript`; `nosniff` is right and is why the browser refuses it today. Send `X-XSS-Protection: 0` or remove it.

Deliverable: Corrected headers for R1 to R4 and `/widget`, with one sentence per change.

Scope: Paper exercise: the fixture is complete. Reproduce any result with a local server and a current browser.

## Executable practice

- [CSP builder lab: Build a strict Content-Security-Policy](https://softwaresecurity.com/labs/csp-policy/)

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [RFC 6797: HTTP Strict Transport Security (HSTS)](https://www.rfc-editor.org/rfc/rfc6797.html). November 2012.
- [HSTS preload list: deployment recommendations](https://hstspreload.org/#deployment-recommendations)
- [W3C Content Security Policy Level 3: strict CSP](https://www.w3.org/TR/CSP3/#strict-csp). Working Draft, 16 September 2026.
- [OWASP HTTP Security Response Headers Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html)
- [OWASP ASVS, V3.4 Browser Security Mechanism Headers](https://github.com/OWASP/ASVS/blob/v5.0.0_release/5.0/en/0x12-V3-Web-Frontend-Security.md#v34-browser-security-mechanism-headers). Version 5.0.0.
- [draft-ietf-httpbis-rfc6265bis: cookie name prefixes](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-22#section-4.1.3). Draft 22.
- [MDN: Referrer-Policy and the Origin header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy#effect_on_the_origin_header)
- [Django: Content Security Policy](https://docs.djangoproject.com/en/6.1/ref/csp/). Django 6.1.
- [Next.js: how to set a Content Security Policy](https://nextjs.org/docs/app/guides/content-security-policy). Next.js 16.4.
- [nginx: add_header and add_header_inherit](https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header)
- [The Register: British Airways fined £20m for Magecart hack](https://www.theregister.com/security/2020/10/16/british-airways-fined-20m-for-magecart-hack-that-exposed-400k-folks-credit-card-details-to-crooks/616222). 16 October 2020.