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.

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

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 47 minutes: 15 to read, 10 on the code and 22 for the exercise

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.
Go to the guided practice

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.

Send each header where the browser uses it
ResponseSendWhy
HTML documents, including error pagesHSTS, CSP with frame-ancestors, X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy, COOPThese responses run script, render forms and can be framed.
JSON and other API responsesHSTS, nosniff, CORP, Cache-Control: no-store when the data is personalStops a response being sniffed as HTML or read by another site's no-cors request.
Scripts, stylesheets, images and fontsHSTS, nosniff with a correct Content-Type, CORPnosniff makes the browser refuse scripts and styles served with the wrong type.
User-uploaded filesnosniff, Content-Disposition: attachment, CSP sandbox, ideally a separate originKeeps an upload from rendering as an active page on your origin.
HTTP-to-HTTPS redirectsNo HSTS on the HTTP hop; the full set on the HTTPS responseBrowsers 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.

ConfigurationResponse headers and markupHTTP
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.

Shell
# 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.

ConfigurationHTTP
# 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.

ConfigurationHTTP
# 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'.

Configurationapp.jsJavaScript
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.

ConfigurationSecurityConfig.javaJava
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.

ConfigurationProgram.csC#
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.

Configurationsettings.pyPython
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.

Configurationnext.config.mjsJavaScript
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.

Configurationproxy.tsTypeScript
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.

Configurationsecurity-headers.confnginx
# 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.

Testcheck-headers.shShell
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 scenario material
Plain 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. 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 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. 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.

    Expected result and reasoning

    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. 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 includeSubDomains although every subdomain serves HTTPS, so after a staged ramp send max-age=63072000; includeSubDomains on HTTPS responses from the registrable domain.

  4. 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-referrer sends Origin: null on the form post, which Django's CSRF middleware rejects with 403. Switch to strict-origin-when-cross-origin.

  5. 5. Close the remaining gaps

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

    Expected result and reasoning

    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.

Your deliverable: Corrected headers for R1 to R4 and /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

CSP builder lab: Build a strict Content-Security-Policy

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.

  1. RFC 6797: HTTP Strict Transport Security (HSTS). November 2012.
  2. HSTS preload list: deployment recommendations
  3. W3C Content Security Policy Level 3: strict CSP. Working Draft, 16 September 2026.
  4. OWASP HTTP Security Response Headers Cheat Sheet
  5. OWASP ASVS, V3.4 Browser Security Mechanism Headers. Version 5.0.0.
  6. draft-ietf-httpbis-rfc6265bis: cookie name prefixes. Draft 22.
  7. MDN: Referrer-Policy and the Origin header
  8. Django: Content Security Policy. Django 6.1.
  9. Next.js: how to set a Content Security Policy. Next.js 16.4.
  10. nginx: add_header and add_header_inherit
  11. The Register: British Airways fined £20m for Magecart hack. 16 October 2020.

Reference information

SDLC phase
Development
OWASP WSTG v4.2 tests
WSTG-CONF-07 · WSTG-CLNT-09

Download this guide as Markdown