Security headers for Next.js
Add an async headers() function to next.config.ts that returns your security headers for the source "/:path*", and every route will send them. For a Content-Security-Policy that actually stops injected scripts, generate a nonce per request in proxy.ts instead of hard-coding the policy.
Updated
Check your live site now
Free, no signup, read only. A grade and plain fixes in seconds.
Which security headers a Next.js app needs
Next.js doesn't add security headers for you. It does add X-Powered-By: Next.js to every response unless you turn it off. These are the headers worth sending, and what each one does:
- Strict-Transport-Security (HSTS): tells the browser to use https for your domain for the next year, even if someone types http or follows an old link.
- Content-Security-Policy (CSP): a list of places scripts, styles, images and connections may come from. If an attacker gets a script tag into your page, a strict CSP stops it running.
- X-Frame-Options, or frame-ancestors inside the CSP: stops other sites loading your pages in an invisible iframe and tricking users into clicking buttons (clickjacking).
- X-Content-Type-Options: nosniff: stops the browser guessing a file's type, so an uploaded text file can't be run as a script.
- Referrer-Policy: controls how much of your URL is sent to other sites when a user clicks away. strict-origin-when-cross-origin sends only your domain.
- Permissions-Policy: switches off browser features your app never uses, such as the camera, microphone and location.
The next.config.ts security headers block
Add this to next.config.ts in the root of your project. If the file already has a config object, merge the headers() function into it rather than creating a second export. The source "/:path*" matches every path, including API routes and static files.
const nextConfig = {
async headers() {
return [
{
source: "/:path*",
headers: [
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
{ key: "Content-Security-Policy", value: "default-src 'self' https: data: blob: 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'; upgrade-insecure-requests" },
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
],
},
];
},
};
export default nextConfig;Restart the dev server after editing next.config.ts; it isn't hot reloaded. The CSP in this block is a starter policy. It blocks plugins, base tag hijacking, framing by other sites and form posts to other domains, and it won't break a typical app. It still allows inline scripts and eval, so a header checker will report it as weak. That is expected: the next section is how you tighten it.
To drop the X-Powered-By header, add one line to the same config object:
const nextConfig = {
poweredByHeader: false,
// async headers() { ... }
};Moving to a nonce-based CSP with proxy.ts
A CSP only protects you from injected scripts once script-src stops allowing 'unsafe-inline'. The App Router renders small inline scripts of its own, so you can't just delete 'unsafe-inline'. The supported route is a nonce: a random value generated for each request, added to the CSP and stamped on every script Next.js renders. An injected script doesn't know the nonce, so the browser refuses to run it.
In Next.js 16 this lives in proxy.ts at the project root (the same file was called middleware.ts in earlier versions). Next.js reads the nonce from the request's Content-Security-Policy header and adds it to its own framework scripts and your page bundles automatically. For a third party script loaded with next/script, pass the nonce prop, reading the value in a server component with (await headers()).get("x-nonce").
import { type NextRequest, NextResponse } from "next/server";
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const isDev = process.env.NODE_ENV === "development";
const csp = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ""}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' blob: data:",
"font-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'self'",
"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 } });
// Start in report-only mode. Rename to Content-Security-Policy once the console is clean.
response.headers.set("Content-Security-Policy-Report-Only", csp);
return response;
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};- Remove the Content-Security-Policy line from next.config.ts so the proxy is the only place the policy is set.
- Deploy with the Report-Only header and click through every page, including login, checkout and anything with third party widgets. Blocked resources show up as CSP errors in the browser console without anything actually breaking.
- Add any legitimate hosts you see (an analytics endpoint in connect-src, an image CDN in img-src) and repeat until the console is clean.
- Change the response header name to Content-Security-Policy to enforce it.
Nonces need dynamic rendering. A page built at build time has no request, so it can't carry a per-request nonce, and Partial Prerendering doesn't work with a nonce-based CSP. If you'd rather keep static pages, Next.js has experimental hash-based support (the sri option in next.config) as an alternative.
Next.js security header gotchas
- Static export ignores both headers() and proxy.ts. If next.config has output: "export", set headers on your host instead, for example in vercel.json or a Netlify _headers file.
- If the CSP is set in two places, the browser receives two policies and enforces both, so a resource has to pass each one. Keep the policy in one place.
- The development server needs 'unsafe-eval' for React's debugging tools. Production doesn't, which is why the proxy example only adds it when NODE_ENV is development.
- 'unsafe-inline' in style-src is common and far lower risk than in script-src, because styles can't run code. Tighten scripts first.
- frame-ancestors in the CSP replaces X-Frame-Options in current browsers. Sending both is fine; keep them consistent (SAMEORIGIN matches 'self', DENY matches 'none').
How to check your Next.js headers
Run this against your deployed site, not localhost, because hosts and CDNs can add or strip headers on the way out:
curl -sI https://your-app.com | grep -i -E 'strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy|x-powered-by'You should see one line per header and no x-powered-by line. curl -I sends a HEAD request. If your app answers HEAD differently from a normal page load, this variant makes a GET and prints only the headers:
curl -s -D - -o /dev/null https://your-app.comA header check on the live URL confirms the same thing and also grades how strict the CSP is.
Questions
Should I set security headers in next.config.ts or in proxy.ts?
Put the fixed headers (HSTS, X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy) in next.config.ts. Put the CSP in proxy.ts once you move to nonces, because the value changes on every request and next.config can't do that.
Does Vercel add security headers to a Next.js app automatically?
Vercel serves every deployment over https, but your CSP, frame protection, nosniff and Referrer-Policy are up to you. Run curl -sI against your deployment to see exactly what is already there before adding anything.
Why does my CSP still get flagged after I added it?
Most likely script-src (or default-src, if there's no script-src) still allows 'unsafe-inline' without a nonce, or allows any https: host. Either one lets an injected script run. The nonce setup above fixes both.
Is middleware.ts still supported?
In Next.js 16 the file convention is deprecated and renamed to proxy.ts, with the exported function renamed to proxy. Next.js ships a codemod, npx @next/codemod@canary middleware-to-proxy, that renames both.