Vercel & Next.js Security Header Management
This guide is part of the Server & Platform Implementation Guides reference. It covers the three mechanisms Next.js and Vercel expose for emitting HTTP security headers — the next.config.js headers() async function, the vercel.json headers array, and Next.js middleware — and the precedence rules that decide which one wins when they overlap. Pick the mechanism that matches the requirement: static headers that never change per request belong in build-time config; per-request values such as Content-Security-Policy nonces require middleware.
Threat Model & Protocol Mechanics
Serverless platforms move header enforcement off the origin host and onto a distributed edge network. On Vercel, a response can be assembled at three distinct points: at build time (static HTML and _next/static assets), at the Vercel edge (the platform’s own router and CDN), and at request time inside a function or middleware. A security header is only as reliable as the layer that emits it, and the failure mode that matters is a header that is present on the route you tested but absent on a sibling route, a cached static asset, or an error response.
The attacks these headers neutralize are the standard set. Without Strict-Transport-Security, a single plaintext request before the first HTTPS redirect is exploitable for SSL stripping. Without Content-Security-Policy, an injected <script> executes with full origin privileges. Without X-Frame-Options or a CSP frame-ancestors directive, the page is framable for clickjacking, and without X-Content-Type-Options: nosniff, a JSON or text endpoint can be coerced into script execution by MIME sniffing.
Next.js gives you three distinct emission mechanisms, and they differ on exactly two axes that determine correctness: when the header value is computed (build time vs. per request) and which routes the mechanism can actually reach.
next.config.jsheaders()is anasyncfunction evaluated at build time. The returned values are baked into the route manifest and applied by the Next.js server to matching routes. It is static — it cannot read the incoming request, so it cannot produce a per-request nonce. It is ignored entirely whenoutput: 'export'is set.vercel.jsonheadersis a platform-level array applied by the Vercel edge router, independent of the Next.js runtime. It applies even to fully static exports and to assets the Next.js server never touches, which makes it the correct home for headers on a static-export deployment.- Next.js middleware runs on the Edge runtime per request, before the response is finalized. It is the only mechanism that can read the request and mutate the response, so it is the only place to mint a fresh CSP nonce or apply a header conditional on path, cookie, or geography.
Precedence and overlap are the trap. On a route matched by both next.config.js and middleware, the middleware-set header wins — middleware runs later and overwrites. vercel.json headers are applied by the platform router and can coexist with Next.js-emitted headers, producing duplicate header lines if the same key is defined in both places. Two Content-Security-Policy headers do not merge into a stricter union; browsers intersect the policies, which usually means the most restrictive one silently breaks the page. The rule that prevents this: define each security header in exactly one mechanism.
The static-export caveat deserves its own callout. With output: 'export', there is no Next.js server at runtime — only static files. The headers() function is silently dropped at build. On Vercel, your only header surface is vercel.json; on any other host, it is that host’s own configuration (see Nginx or Apache).
Mechanism Comparison
The table below is the at-a-glance selector. The detailed trade-off between the two build-time options is covered in the dedicated vercel.json vs next.config.js comparison.
| Mechanism | Evaluation | Per-request values | Applies to static export | Applies to _next/static assets |
Best for |
|---|---|---|---|---|---|
next.config.js headers() |
Build time | No | No (silently dropped) | Yes, with explicit source |
Static security headers on a server-rendered app |
vercel.json headers |
Vercel edge router | No | Yes | Yes | Static exports; platform-wide headers independent of the runtime |
| Next.js middleware | Per request (Edge) | Yes | No (no runtime) | Only routes the matcher includes |
CSP nonces and any header conditional on the request |
Two non-obvious consequences fall out of this table. First, on a static export only vercel.json survives. Second, a CSP nonce can never come from next.config.js or vercel.json because both compute their values before the request exists — nonces are middleware-only, which is why the CSP-with-nonces in middleware workflow is a separate procedure.
Platform-Specific Implementation
next.config.js headers()
The headers() export returns an array of objects, each pairing a source path pattern with a headers array of key/value objects. It is the right home for static headers — HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy — that are identical for every visitor. Full syntax and source-pattern semantics are in the next.config.js headers array setup reference.
// next.config.js
module.exports = {
async headers() {
return [
{
// Next.js path-matching syntax, not raw regex.
source: '/(.*)',
headers: [
// 2 years; includeSubDomains + preload required for the preload list.
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
// Legacy clickjacking control; pair with CSP frame-ancestors.
{ key: 'X-Frame-Options', value: 'DENY' },
// Forces strict MIME checking; blocks sniffing of text/JSON into script.
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
],
},
];
},
};
Security note: there is no always flag here as there is in Nginx or Apache — Next.js emits these headers on all responses it generates for matching routes, including its error pages, so the equivalent guarantee is implicit. The hard boundary is that headers() is evaluated by next build and ignored under next dev; never trust a dev server to confirm header presence.
vercel.json headers
vercel.json headers are applied by the Vercel platform router, not the Next.js runtime. Use this mechanism when the deployment is a static export (no Next.js server exists to run headers()), or when you want headers enforced at the platform edge regardless of how the app is built.
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" },
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }
]
}
]
}
Security note: source in vercel.json uses the same path-to-regexp matching as redirects and rewrites. Do not also define these same keys in next.config.js for a server-rendered build — both layers will emit them and the response carries duplicates.
Next.js middleware (CSP nonce)
Middleware is the only mechanism that runs per request, so it is the only place a fresh Content-Security-Policy nonce can be generated. The middleware mints the nonce, sets it on the outgoing response header, and forwards it on a request header so the rendered page can stamp the same nonce onto its inline <script> tags.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Web Crypto, available in the Edge runtime (Node's crypto is not).
const nonce = btoa(crypto.randomUUID());
const csp = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'unsafe-inline'`,
`object-src 'none'`,
`base-uri 'self'`,
`frame-ancestors 'none'`,
].join('; ');
// Forward the nonce to the render layer on a request header.
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set('Content-Security-Policy', csp);
return response;
}
export const config = {
// Skip static assets and the favicon; they need no per-request CSP.
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
Security note: because middleware runs after next.config.js header resolution, a Content-Security-Policy set here overrides any CSP set in next.config.js on the same route. Keep CSP in exactly one place — middleware if you use nonces, config if you use a static policy. The full nonce-propagation procedure, including reading x-nonce in a Server Component, is in the CSP with nonces in middleware reference.
Note on origin/CDN duplication
The single most common production defect on Vercel is the same security header arriving twice. It happens when a header is declared in both next.config.js and vercel.json, or in next.config.js and middleware on an overlapping route, or when an upstream proxy in front of Vercel adds its own copy. Duplicate Strict-Transport-Security is harmless (browsers take the first). Duplicate Content-Security-Policy is not — the browser enforces the intersection of all policies, so a second, stricter or differently-scoped CSP silently breaks resources the first one allowed. Audit with curl -sI (below) and count occurrences of each key; any security key appearing twice is a bug.
Verification & Diagnostic Workflows
Build a production bundle locally first — next dev does not run headers() and will give false negatives.
# Build and serve the production app, then inspect raw headers.
npm run build && npm start
curl -sI http://localhost:3000/
Expected output (header order may vary; casing is normalized to lowercase by Next.js):
HTTP/1.1 200 OK
strict-transport-security: max-age=63072000; includeSubDomains; preload
x-frame-options: DENY
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
content-security-policy: default-src 'self'; script-src 'self' 'nonce-MWE3...' 'strict-dynamic'; ...
To confirm behavior on the real edge, deploy a preview and inspect it. Preview deployments carry the same header configuration as production:
# Create a preview deployment and capture its URL.
vercel deploy
# Inspect the deployed response, including Vercel's own diagnostics.
curl -sI https://<preview-url>.vercel.app/ | grep -iE 'strict-transport|content-security|x-frame|x-content-type|x-vercel-cache|x-vercel-id'
The x-vercel-cache value (HIT, MISS, STALE) tells you whether you are looking at a cached static response or a freshly computed one — relevant when a middleware-set CSP nonce should differ on every uncached request. To detect duplicates, pipe through a counter:
curl -sI https://<preview-url>.vercel.app/ | grep -ic 'content-security-policy'
# Expected: 1 (any value >1 is a duplicate-header bug)
Troubleshooting, Misconfigurations & Safe Rollback
- Headers missing on
_next/staticassets:headers()only applies to routes itssourcepatterns match; a singlesource: '/(.*)'covers application routes but you must verify static asset paths explicitly. Symptom → fix:curl -sIon a/_next/static/...URL shows no security headers → add an explicitsource: '/_next/static/(.*)'entry, or move the headers tovercel.json, which the platform applies to every path including static files. - Middleware not running on a route (matcher gap): Symptom → fix: a route is missing its per-request CSP while sibling routes have it → the
matcherregex excludes that path. Test the matcher against the failing path and widen it; remember the negative-lookaheadmatcherexcludes_next/static,_next/image, andfavicon.icoby design. - Duplicate
Content-Security-Policybreaking resources: Symptom → fix:curl -sI | grep -ic content-security-policyreturns2, and the browser console shows CSP violations for assets that should be allowed → the policy is defined in two mechanisms. Remove the declaration from all but one (keep it in middleware if nonces are involved). headers()ignored entirely: Symptom → fix: no configured header appears even afternext build→ check foroutput: 'export'innext.config.js, which dropsheaders()silently. Move headers tovercel.json.- Safe rollback: Headers are non-destructive, but an over-strict CSP can blank the page. To roll back, delete the offending header from its single source of truth, run
rm -rf .nextto purge the build cache, thennpm run build && npm startto confirm the baseline. Re-introduce strict directives behindContent-Security-Policy-Report-Onlybefore enforcing them.
Frequently Asked Questions
Why do my headers appear in production but not in next dev?
next dev does not run the headers() export — it is only evaluated by next build. Always verify with npm run build && npm start or on a vercel deploy preview, never against the dev server.
Can I generate a CSP nonce in next.config.js?
No. headers() is evaluated at build time and has no access to the incoming request, so it cannot produce a per-request value. Nonces must be minted in middleware, which runs per request on the Edge runtime.
Which mechanism wins when a header is set in both next.config.js and middleware?
Middleware. It runs after next.config.js header resolution and overwrites the value on any matched route. Define each security header in exactly one mechanism to avoid dead config and duplicate-header bugs.
How do I set headers on a static export?
With output: 'export' there is no Next.js server, so headers() is dropped. On Vercel, use the vercel.json headers array, which the platform router applies to static files. On other hosts, use that host’s configuration such as Nginx.
Why does my page break after adding a second CSP header?
Browsers enforce the intersection of all Content-Security-Policy headers, not the most permissive one. A second CSP — from vercel.json, middleware, or an upstream proxy — silently tightens the effective policy and blocks resources. Keep exactly one CSP header per response.