The Security Headers Every Next.js App Should Ship
From CSP and HSTS to killing the X-Powered-By tell, the exact header set that hardens a production Next.js deploy in one config file.
There is a category of security work that costs almost nothing, ships in a single config file, and quietly closes off whole classes of attack before they reach your code. HTTP security headers are that category. They are instructions the browser obeys on your behalf: do not render this site in a frame, do not guess content types, do not send my full URL to other sites, only run scripts I explicitly allow. None of it touches your application logic, and all of it raises the floor.
The reason it gets skipped is that the defaults are silent. A Next.js app with no headers configured works perfectly in the demo and ships X-Powered-By: Next.js to the whole internet, advertising the exact framework and version an attacker should target first. Nothing breaks, so nobody notices, until a pen tester or a real incident points it out. Here is the header set worth shipping on every production deploy, and what each one actually buys you.
Kill the framework tell first
The single easiest win is removing the X-Powered-By header. By default Next.js announces itself on every response. That tells an attacker which framework you run and narrows their search to known issues for that framework and version. It is free intelligence you are handing out, and turning it off is one line.
// next.config.ts
const nextConfig = {
poweredByHeader: false,
};
That is not a meaningful defence on its own, but it is the kind of low-effort information leak that a careful team closes, and its absence is a tell that the rest of the headers are probably missing too. It is the same instinct that keeps you from leaking your admin login URL in redirects and errors: every detail you hand an attacker for free narrows their search.
The static header set that belongs on every response
These headers are the same on every request, so they go in next.config.ts under the headers() function. Set them once and they apply across the app.
X-Content-Type-Options: nosniff. Stops the browser from guessing a response's content type. Without it, a file you serve as text could be interpreted as a script, which matters a lot on any app that lets users accept file uploads without opening a remote code hole. With it, the browser respects the type you declared and nothing else.X-Frame-Options: DENY. Stops your pages from being embedded in an iframe on another site, which is the mechanism behind clickjacking. An attacker cannot overlay your real login form under a fake button if the browser refuses to frame your page at all. For new projects the CSPframe-ancestorsdirective does the same job more flexibly, but shipping both covers older browsers too. Headers pair with browser-level access controls like locking down CORS before it hands over your session tokens.Referrer-Policy: strict-origin-when-cross-origin. Controls how much of your URL gets sent to other sites when a user follows a link off your page. The strict-origin-when-cross-origin value sends the full path within your own site but only the bare origin to external sites, so query strings and paths that might contain tokens or identifiers do not leak across origins.Permissions-Policy: camera=(), microphone=(), geolocation=(). Explicitly denies access to powerful device APIs you do not use. If your app never needs the camera, declaring that means a compromised script or embedded content cannot quietly ask for it either.
async headers() {
return [{
source: "/:path*",
headers: [
{ 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=()" },
],
}];
}
HSTS, but only in production
Strict-Transport-Security tells the browser to only ever reach your site over HTTPS, for a set duration. Once a browser has seen it, it will not even attempt an insecure connection, which closes off the window where a downgrade attack could strip TLS on a first request.
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
The critical detail: gate this on production. HSTS in a development environment is a trap, because Safari in particular honours it on localhost, pins HTTPS for the full two-year max-age, caches that decision, and then refuses to load your local dev server's assets over plain HTTP, leaving you with a broken, unstyled page that persists even after you remove the header. That exact failure is unpacked in why your dev site breaks in Safari but not Chrome on localhost. Send HSTS only when NODE_ENV === "production", and the upgrade-insecure-requests CSP directive along with it, so your local builds stay reachable.
Content Security Policy: the one that earns its keep
CSP is the heavyweight. It tells the browser exactly which sources are allowed to load scripts, styles, images, fonts, and connections, and it refuses everything else. A CSP that disallows inline scripts and untrusted origins turns most cross-site scripting from a full compromise into a script that never executes, because even if an attacker injects a <script>, the browser will not run it unless it matches your policy.
CSP is also the one most likely to break your app if you bolt it on carelessly, because real apps load analytics, fonts, embedded widgets, and sometimes inline styles. The directives that do most of the work:
default-src 'self'as the baseline, then loosen per resource type only where you genuinely need to.script-srclocked down hard. Avoidunsafe-evalin production entirely, and prefer a nonce-based approach to inline scripts overunsafe-inline.frame-ancestors 'none'to back up X-Frame-Options.upgrade-insecure-requestsin production so any stray HTTP subresource gets bumped to HTTPS.
Because a strong CSP often needs a per-request nonce, this is the one header that frequently moves from the static next.config.ts into middleware, where you can generate a fresh nonce per request and inject it into both the header and your inline scripts. Start with a static policy in the config, then graduate to nonce-based CSP in middleware when you need inline scripts to run safely.
The honest part: a good CSP takes iteration. You ship it in report-only mode first, watch what it would have blocked, allow-list the legitimate sources, and only then enforce it. We learned a specific version of this the careful way: a third-party chat widget whose script loader was allow-listed but whose XHR and websocket endpoints were not would have silently failed for every visitor, and the only thing that caught it was running the real page through a browser and watching the network panel, not reading the config. CSP rewards testing the actual customer journey, not just the syntax. The allow-list itself is only half the story, since pinning third-party scripts with subresource integrity is what stops an allowed source from serving you altered code.
Test what you shipped, do not assume it
Headers are easy to write and easy to get subtly wrong. A typo in a directive, a header set in the config that middleware later overrides, a CSP that blocks your own fonts, all of these pass typecheck and fail in the browser. After you ship the set, verify it. curl -sI https://yoursite shows you exactly what the server sends, and the various security-header scanners grade the result and tell you what is missing. Confirm the X-Powered-By is actually gone, the HSTS is present in production and absent in dev, and the CSP does not block anything your real pages need.
This whole set is roughly one config file and one middleware function, and it is the kind of baseline that should never be a special project. It is part of what "production-ready" means, alongside the security a small team needs before its first incident and the simple fact of what skipping security early really costs a startup when the bill comes due. When we run a security audit, the header set is one of the first things checked, because its absence is both a vulnerability and a signal about how the rest of the app was built. If you want a fast read on what your site currently sends, our free instant security scan checks the headers among other things and gives you the list in seconds.
The reason to do this is not that any single header is a silver bullet. It is that together they remove the cheap attacks, the framing, the content-type confusion, the URL leakage, the injected scripts, so the only ways into your app are the ones that require real effort. Raising the floor that much, for the cost of one config file, is the best security trade you will make all quarter.






