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-ageup in stages, and treat preload as a one-way door. - Use
frame-ancestorsagainst clickjacking;X-Frame-Optionsis only a fallback. - Don't send
Referrer-Policy: no-referrerif your CSRF check reads theOriginheader on form posts. - Check 404s, redirects and static files: proxies and default error handlers often drop headers.
- Send
X-XSS-Protection: 0or nothing; current browsers no longer have the filter it controlled.
| 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 |
- 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. - Load the same page twice: the CSP nonce must differ between responses and match the nonce attribute on every script element.
- 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.
- Confirm that plain-HTTP requests get a 301 without
Strict-Transport-Securityand HTTPS responses carry it, which proves the application sees the original scheme behind the proxy. - Run Mozilla's HTTP Observatory and Google's CSP Evaluator against staging, and check the domain on hstspreload.org before sending
preload. - Add an integration test (supertest, MockMvc or Django's test client) that asserts the exact header values, so a refactor cannot drop them silently.
- Applies to
- helmet (Express 5) 8.3
- Spring Security 7.1 (Spring Boot 4.1)
- ASP.NET Core 10.0
- Django 6.1; built-in CSP since 6.0
- Next.js 16.4; proxy.ts since 16.0
- nginx 1.30 stable; add_header_inherit since 1.29.3
Web applications and APIs served to browsers. Support for newer headers varies, so verify behavior in the browsers your users run.
- Part of
- Workflows: Fix a finding, Secure a feature
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
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.
Before you start
Developers and reviewers who own the HTTP responses of a web application
- 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.
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. |
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, then604800(one week), then2592000(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=0clears the policy, but only in browsers that can still load the site over HTTPS to receive it.
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.
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/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. -->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.
# 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 cspClickjacking 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.
# 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.orgX-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.
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.
# 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-corphelmet 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'.
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 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.
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);
}
}
}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.
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();
});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.
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 }}">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.
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 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.
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' },
],
}],
};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.
# 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";
}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-uriandsandboxare ignored there, andX-Frame-Optionsdoes nothing in markup. - Obsolete headers left in place:
X-Frame-Options: ALLOW-FROM,X-XSS-Protection: 1; mode=block,Expect-CTandPublic-Key-Pins. includeSubDomainson 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 removingeval.Referrer-Policy: no-referrercombined with Origin-based CSRF checks, which then reject legitimate form posts.
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.
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.
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)'
doneGuided 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 scenario material
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.Expected result and reasoning
It runs: with no nonce or hash,
'unsafe-inline'stays in force, and the allowlist trusts every script oncdn.example.net. Replace it withscript-src 'nonce-{per-response}' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none', first asContent-Security-Policy-Report-Only.2. Fix framing
Explain what
X-Frame-Options: ALLOW-FROMdoes in a current browser, then give the framing headers for/widgetand for every other page.Expected result and reasoning
Browsers ignore
ALLOW-FROM, so any site can frame R1. Sendframe-ancestors 'none'plusX-Frame-Options: DENYon normal pages, andframe-ancestors https://partner.example.orgon/widgetonly.3. Repair HSTS
Evaluate the HSTS headers in R1 and R2 and state the corrected behaviour.
Expected result and reasoning
Browsers ignore R2's header, which arrived over plain HTTP; keep the 301 and drop it. R1 lacks
includeSubDomainsalthough every subdomain serves HTTPS, so after a staged ramp sendmax-age=63072000; includeSubDomainson 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.
Expected result and reasoning
Referrer-Policy: no-referrersendsOrigin: nullon the form post, which Django's CSRF middleware rejects with 403. Switch tostrict-origin-when-cross-origin.5. Close the remaining gaps
Correct the cookie, R3, R4 and the
X-XSS-Protectionheader.Expected result and reasoning
Rename the cookie
__Host-sidand send it withPath=/; Secure; HttpOnly; SameSite=Lax. Make the 404 handler, or the proxy withalways, send the same headers as R1. Serveapp.jsastext/javascript;nosniffis right and is why the browser refuses it today. SendX-XSS-Protection: 0or remove it.
/widget, with one sentence per change.Paper exercise: the fixture is complete. Reproduce any result with a local server and a current browser.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Repair the code
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- RFC 6797: HTTP Strict Transport Security (HSTS). November 2012.
- HSTS preload list: deployment recommendations
- W3C Content Security Policy Level 3: strict CSP. Working Draft, 16 September 2026.
- OWASP HTTP Security Response Headers Cheat Sheet
- OWASP ASVS, V3.4 Browser Security Mechanism Headers. Version 5.0.0.
- draft-ietf-httpbis-rfc6265bis: cookie name prefixes. Draft 22.
- MDN: Referrer-Policy and the Origin header
- Django: Content Security Policy. Django 6.1.
- Next.js: how to set a Content Security Policy. Next.js 16.4.
- nginx: add_header and add_header_inherit
- The Register: British Airways fined £20m for Magecart hack. 16 October 2020.
Reference information
- SDLC phase
- Development
- Role
- Developers
- CAPEC attack patterns
- OWASP WSTG v4.2 tests
- WSTG-CONF-07 · WSTG-CLNT-09