- Security
- Web
- Next.js
Security Headers Explained: CSP, HSTS and the Rest
A practical guide to CSP with nonces and strict-dynamic, HSTS and preload, frame-ancestors, Referrer-Policy, Permissions-Policy and COOP, with a Next.js config.
· 7 min read · Lina Source LLC
Security headers are instructions your server gives the browser: only run these scripts, only talk to me over HTTPS, do not let other sites frame this page. They do not fix bugs in your code, but they limit what an attacker can do with one. A cross-site scripting hole behind a strict Content Security Policy is a much smaller problem than the same hole without one.
Most of these headers are one line of configuration. The exception is CSP, which needs planning. This guide covers what each header does, sensible values for a typical web app, and how to roll them out without breaking production.
Content-Security-Policy
CSP tells the browser which sources of scripts, styles, images, frames and connections are allowed. Its main job is to stop injected scripts from running. An allowlist of domains sounds like the obvious approach, but it has a long history of bypasses: any allowed CDN that hosts user content or old library versions can be used to load attacker-controlled script.
Nonces and strict-dynamic
The approach that holds up is a nonce-based policy. The server generates a random value per response, puts it in the CSP header and adds it as a nonce attribute to each script tag it renders. Injected script tags do not know the nonce and are blocked. Adding 'strict-dynamic' lets scripts loaded by a trusted script load further scripts, which keeps bundlers and tag managers working without listing every domain.
Content-Security-Policy:
default-src 'self';
script-src 'nonce-4AEemGb0xJptoIGFP3Nd' 'strict-dynamic';
style-src 'self' 'nonce-4AEemGb0xJptoIGFP3Nd';
img-src 'self' data: https:;
connect-src 'self';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
form-action 'self';
upgrade-insecure-requestsThe header is sent on a single line; it is wrapped here for reading. A few directives in that policy do more than they appear to. object-src 'none' blocks legacy plugin content. base-uri 'none' stops an injected base tag from redirecting relative script URLs. form-action 'self' prevents injected forms from posting credentials elsewhere. The nonce must be unpredictable and new on every response, which means pages using it cannot be served from a static cache.
Roll out with Report-Only
A strict CSP deployed blind will break something: an analytics snippet, an inline event handler, a third-party widget. Ship the policy first as Content-Security-Policy-Report-Only. The browser applies nothing but reports every violation to the endpoint named in the report-to or report-uri directive. Collect reports for a while, fix or allow what is legitimate, then switch the header name to enforce. Keep reporting on after enforcement, because new violations are either regressions or attacks. Expect noise in the reports from browser extensions, which inject their own scripts into pages; filter those out by source before deciding what to allow.
Strict-Transport-Security
HSTS tells the browser to use HTTPS for your domain for a set period, even if a user types http:// or clicks an old link. That closes the window where a network attacker could intercept the first plain HTTP request and strip the redirect to HTTPS. Browsers only honor the header when it arrives over HTTPS, and they also refuse to let users click through certificate errors on an HSTS domain, which is the point but also the risk if a certificate lapses.
Start with a short max-age, such as a day, confirm nothing breaks, then raise it to one or two years. includeSubDomains extends the rule to every subdomain, so verify that all of them serve valid HTTPS first, including old marketing sites and internal tools on the same domain.
The preload directive, combined with a submission to the browser preload list, bakes your domain into browsers so even the very first visit uses HTTPS. It requires a max-age of at least one year and includeSubDomains. Treat it as a one-way door: removal from the list is possible but takes a long time to reach users, and any subdomain that cannot do HTTPS becomes unreachable in the meantime.
The one-line headers
- X-Content-Type-Options: nosniff. Stops browsers from guessing a content type different from the one you declared, which prevents an uploaded file served as text from being executed as script.
- frame-ancestors (in CSP) and X-Frame-Options: DENY. Control who can embed your pages in a frame, which is the defense against clickjacking. Modern browsers use frame-ancestors and ignore X-Frame-Options when both are set; keep X-Frame-Options for older clients. Use 'self' or SAMEORIGIN if you frame your own pages.
- Referrer-Policy: strict-origin-when-cross-origin. Sends only your origin, not the full path and query string, to other sites. This keeps tokens and IDs in URLs from leaking to third parties. Use no-referrer for especially sensitive pages.
- Permissions-Policy. Turns off browser features you do not use, such as camera=(), microphone=(), geolocation=() and payment=(), so injected or embedded code cannot request them.
- Cross-Origin-Opener-Policy: same-origin. Puts your page in its own browsing context group, so a window opened by another site cannot keep a reference to it. Use same-origin-allow-popups if you rely on OAuth or payment popups.
- Cross-Origin-Resource-Policy: same-origin or same-site. Tells browsers not to let other origins load your responses as images, scripts or other subresources. Use same-site if you serve assets from a sibling subdomain, and cross-origin for assets meant to be embedded elsewhere.
You can drop X-XSS-Protection. The filter it controlled has been removed from modern browsers, and CSP is the replacement. If a scanner insists, set it to 0. Likewise, avoid headers that only add information for an attacker: X-Powered-By and detailed Server banners reveal your framework and version for free. In Next.js, set poweredByHeader: false in the config to remove the first one.
A Next.js configuration
Static headers belong in next.config.ts, where the headers() function applies them to every route. The values below are a sensible default for an app that does not embed itself in frames, does not use the camera or location, and serves its own assets. Adjust each one to what your app actually does rather than copying them blind.
// next.config.ts
import type { NextConfig } from "next";
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=(), payment=()",
},
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Resource-Policy", value: "same-origin" },
];
const nextConfig: NextConfig = {
async headers() {
return [{ source: "/(.*)", headers: securityHeaders }];
},
};
export default nextConfig;The CSP needs a fresh nonce per request, so it is set in middleware instead. Next.js reads the nonce from the request's Content-Security-Policy header and applies it to the framework's own scripts during rendering. You can read it in a server component with headers().get("x-nonce") for your own script tags.
// middleware.ts (in Next.js 16 the file is proxy.ts and the function is proxy)
import { NextResponse, type NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const csp = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'nonce-${nonce}'`,
"img-src 'self' blob: data:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"upgrade-insecure-requests",
].join("; ");
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: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};Pages rendered with a nonce must be rendered dynamically, since a static page would reuse one nonce for every visitor. During the rollout, change the header name in both places to Content-Security-Policy-Report-Only and add a reporting endpoint. In development, React may need 'unsafe-eval' in script-src for its debugging features; add it only when NODE_ENV is development.
How to verify
- Run curl -sI https://your-domain.example/ against the home page, a dynamic page, an API route and a static asset, and read the headers on each. Headers set only on HTML pages are a common gap.
- Open the browser developer tools. CSP violations appear in the console with the directive and the blocked resource, and the Network tab shows the headers that were actually served.
- Check behind your CDN or reverse proxy as well as at the origin. Proxies sometimes strip, duplicate or override headers, and two conflicting CSP headers are both enforced, so the effective policy is stricter than either.
- Use a public checker such as the Mozilla HTTP Observatory for a quick second opinion, and Google's CSP Evaluator to spot weak CSP directives.
- Add a test in CI that requests key routes and asserts the headers are present, so a config refactor cannot silently drop them.
Headers are configuration, which is exactly why they drift: a new route handler that builds its own response, a proxy rule that overrides the defaults, a CSP loosened with 'unsafe-inline' to unblock a release. Review them like code. CodeAuditAgent reads the configuration in a public repository or pasted snippet along with the rest of the code, and reports missing or weakened headers with severity, the quoted config and a suggested fix.
A reasonable order of work: the one-line headers today, HSTS with a short max-age this week, and a nonce-based CSP in Report-Only mode as soon as you can collect the reports.