The six security headers
The browser-enforced response headers a web app ships, built around a nonce-based Content-Security-Policy, to refuse clickjacking, MIME-sniffing, downgrade, and script injection.
Run one command against your deployed app and read what comes back:
curl -I https://app.example.comHTTP/2 200content-type: text/html; charset=utf-8cache-control: private, no-storeThis response is well-formed, and that is the problem.
A browser receiving it will frame your login page inside an attacker’s site, run a script injected through a comment field, and silently fall back to plain http:// the next time someone types your domain without the scheme.
Nothing is broken; you simply never told the browser to refuse.
A handful of response headers close those holes: clickjacking, MIME-sniffing, protocol downgrade, and third-party script injection. One idea ties them together: a security header is a rule the browser enforces, and the server’s only job is to ship the rule.
The browser enforces, the server declares
Section titled “The browser enforces, the server declares”An HTTP response carries two things. The body holds the content, the HTML or JSON. The headers are a separate channel of metadata, and a security header is an instruction in that channel aimed at the browser: which scripts this page may run, whether it may be shown inside a frame, whether the browser must use HTTPS next time it talks to this origin. The server computes the policy string and attaches it; the browser reads it and applies it to the page it just loaded. The server enforces nothing at runtime: it declares the rule and walks away, and the instruction only ever flows one way.
In the diagram below, the server emits a response with a header attached, the policy declared, and the browser applies that rule to the page, the policy enforced.
Content-Security-Policy: … Strict-Transport-Security: … Two consequences follow.
First, headers are cheap. Emitting one costs the server only the bytes of the string, with no per-request inspection, so there is no performance trade to weigh on the five static headers.
Second, they fail silently. The browser is the only enforcer, so if the header is malformed or the visitor’s browser is too old to understand it, the rule simply does not apply and nothing tells you. Shipping the header and enforcing the header are different claims, and only the second protects anyone, which is why this lesson ends with a verification step.
The baseline is six headers in two tiers you treat differently.
| Tier | Header | What it tells the browser to refuse |
|---|---|---|
| Set once | Strict-Transport-Security | Talking to this origin over plain http:// |
| Set once | X-Content-Type-Options | Guessing a response’s type and running it as script |
| Set once | Referrer-Policy | Leaking the full URL to other origins |
| Set once | Permissions-Policy | Reaching for camera, mic, geolocation, payment |
| Set once | X-Frame-Options | Letting old crawlers embed this page in a frame |
| Live attack | Content-Security-Policy | Running any script or connection not on an allowlist |
The first five are configuration-grade hardening: one decision each, made once, never revisited. The sixth, CSP , is the only one that intercepts a running attack, so it is the only one that needs real thought. Tick off the five, then spend your attention on CSP.
Two terms recur. Clickjacking loads your page invisibly inside an attacker’s and tricks a user into clicking it. MIME-sniffing is the browser second-guessing a response’s declared type and running something it shouldn’t.
The five headers you set once
Section titled “The five headers you set once”These five ship from one place, the headers() key in next.config.ts, because they are identical on every response.
async headers() { const isProd = process.env.NODE_ENV === 'production'; return [ { source: '/(.*)', headers: [ // production-only: HSTS must not lock http://localhost to HTTPS ...(isProd ? [{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload', }] : []), { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), payment=()', }, { key: 'X-Frame-Options', value: 'DENY' }, ], }, ];}The source: '/(.*)' applies them to every route.
const isProd = process.env.NODE_ENV === 'production';const headers = [ ...(isProd ? [{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload', }] : []), { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), payment=()', }, { key: 'X-Frame-Options', value: 'DENY' },];HSTS: force HTTPS, production only. max-age=63072000 is two years. Once a browser sees this header, it refuses plain http:// to this origin for that long, closing SSL-strip downgrade attacks. includeSubDomains extends the rule to every subdomain; preload opts into the browser-shipped preload list. Emit it only in production: on localhost it would lock http://localhost to HTTPS and break your dev server.
const isProd = process.env.NODE_ENV === 'production';const headers = [ ...(isProd ? [{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload', }] : []), { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), payment=()', }, { key: 'X-Frame-Options', value: 'DENY' },];nosniff: nothing to tune. It stops the browser from MIME-sniffing a response, such as an uploaded file, and executing it as JavaScript.
const isProd = process.env.NODE_ENV === 'production';const headers = [ ...(isProd ? [{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload', }] : []), { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), payment=()', }, { key: 'X-Frame-Options', value: 'DENY' },];strict-origin-when-cross-origin: the default, set explicitly. It sends only the origin on cross-origin navigations and the full path on same-origin ones. This is already the modern browser default; setting it leaves nothing to chance. The tempting no-referrer looks more secure but breaks legitimate analytics attribution.
const isProd = process.env.NODE_ENV === 'production';const headers = [ ...(isProd ? [{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload', }] : []), { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), payment=()', }, { key: 'X-Frame-Options', value: 'DENY' },];Permissions-Policy: deny features you don’t use. Each feature=() empty allowlist turns that capability off for the whole page, so an injected script can’t reach the camera, mic, geolocation, or Payment Request API. The one entry that changes: the day you ship Stripe Elements, payment=() becomes payment=(self "https://js.stripe.com").
const isProd = process.env.NODE_ENV === 'production';const headers = [ ...(isProd ? [{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload', }] : []), { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), payment=()', }, { key: 'X-Frame-Options', value: 'DENY' },];X-Frame-Options: DENY: the legacy clickjacking defense. It tells the browser no site may frame this page. CSP’s frame-ancestors 'none' (coming up) does the same and more, so you keep this header only for old crawlers that don’t parse CSP.
HSTS is the header; the attack it closes is a downgrade attack , often called SSL-strip.
CSP: the only header that blocks a live attack
Section titled “CSP: the only header that blocks a live attack”The five headers above harden configuration, closing doors that should never have opened.
Content-Security-Policy is different in kind, because it intercepts an attack that is already running inside your page.
An attacker who slips a <script> into a comment, a profile bio, or any spot where user input reaches the DOM has achieved XSS , and that script runs with your user’s full session.
CSP is a per-page allowlist of where code and connections may come from: a script may sit on the page, but if it isn’t on the list the browser refuses to run it, and a script that tries to fetch stolen data to evil.example hits the same wall.
CSP is the hard part here, so we take it in three passes: the directive list, the problem that forces nonces, then nonces themselves.
Pass 1: the allowlist of directives
Section titled “Pass 1: the allowlist of directives”Read the baseline policy for this stack as deny-by-default: each line names one resource type and the few places it may legitimately come from.
default-src 'self';script-src 'self' 'nonce-{NONCE}' 'strict-dynamic';style-src 'self' 'nonce-{NONCE}';img-src 'self' data: blob: https:;font-src 'self';connect-src 'self' https://*.upstash.io https://*.sentry.io https://us.i.posthog.com;frame-ancestors 'none';base-uri 'self';form-action 'self';The floor. default-src is the fallback for every resource type without its own line, and 'self' means same-origin only. Each directive below adjusts this baseline for one specific resource.
default-src 'self';script-src 'self' 'nonce-{NONCE}' 'strict-dynamic';style-src 'self' 'nonce-{NONCE}';img-src 'self' data: blob: https:;font-src 'self';connect-src 'self' https://*.upstash.io https://*.sentry.io https://us.i.posthog.com;frame-ancestors 'none';base-uri 'self';form-action 'self';Scripts and styles, locked to same-origin plus a marked exception. Treat 'nonce-{NONCE}' and 'strict-dynamic' as placeholders that Pass 3 explains. The point today: a script must be same-origin or carry an explicit mark of trust, with no blanket permission for arbitrary inline script.
default-src 'self';script-src 'self' 'nonce-{NONCE}' 'strict-dynamic';style-src 'self' 'nonce-{NONCE}';img-src 'self' data: blob: https:;font-src 'self';connect-src 'self' https://*.upstash.io https://*.sentry.io https://us.i.posthog.com;frame-ancestors 'none';base-uri 'self';form-action 'self';The line that pays off most. connect-src controls where fetch, WebSocket, and sendBeacon may reach, and three vendors are listed: *.upstash.io (rate limiting), *.sentry.io (error monitoring), and us.i.posthog.com (analytics). The policy doubles as a manifest of who the app trusts, so an entry nobody can explain is worth investigating.
default-src 'self';script-src 'self' 'nonce-{NONCE}' 'strict-dynamic';style-src 'self' 'nonce-{NONCE}';img-src 'self' data: blob: https:;font-src 'self';connect-src 'self' https://*.upstash.io https://*.sentry.io https://us.i.posthog.com;frame-ancestors 'none';base-uri 'self';form-action 'self';Images are looser, on purpose. data: allows inline SVG, blob: allows client-generated previews, and https: allows any HTTPS image, such as a user upload or CDN. Images don’t execute, so a wide policy here is acceptable.
default-src 'self';script-src 'self' 'nonce-{NONCE}' 'strict-dynamic';style-src 'self' 'nonce-{NONCE}';img-src 'self' data: blob: https:;font-src 'self';connect-src 'self' https://*.upstash.io https://*.sentry.io https://us.i.posthog.com;frame-ancestors 'none';base-uri 'self';form-action 'self';The real clickjacking defense. frame-ancestors 'none' means no site may embed this page in a frame. It replaces X-Frame-Options: DENY, read by current browsers as part of the policy rather than a separate header.
default-src 'self';script-src 'self' 'nonce-{NONCE}' 'strict-dynamic';style-src 'self' 'nonce-{NONCE}';img-src 'self' data: blob: https:;font-src 'self';connect-src 'self' https://*.upstash.io https://*.sentry.io https://us.i.posthog.com;frame-ancestors 'none';base-uri 'self';form-action 'self';Two narrow injection vectors, closed. base-uri 'self' stops injected markup from rewriting the page’s base URL, which would silently re-point every relative script src. form-action 'self' stops a form from posting to an attacker’s domain.
Two lines carry the 'nonce-{NONCE}' tokens, the crux we have deferred.
Here is why they must exist.
Pass 2: the inline-script problem
Section titled “Pass 2: the inline-script problem”A strict script-src 'self' blocks all inline script: any <script> with code between the tags rather than a src pointing at a same-origin file.
That is the right default, because inline script is the shape an XSS injection takes.
But it collides with your framework: Next.js injects inline <script> tags to bootstrap hydration, and React Server Components stream inline scripts as they render.
Block those legitimate scripts and you block your own hydration, leaving a dead, non-interactive page.
There are three ways to let them through, and two are wrong.
script-src 'self' 'unsafe-inline';The trap. 'unsafe-inline' allows every inline script, including the attacker’s, re-opening the exact hole CSP exists to close. A policy carrying it gives almost no XSS protection. It’s the most common real-world CSP mistake: hit a few CSP errors, paste in 'unsafe-inline' to silence them, ship a policy that does nothing.
script-src 'self' 'sha256-B2yPHKaX…';Right idea, wrong fit. A hash pins one inline script by the SHA-256 of its exact contents, so the browser runs only scripts whose hash is listed. That works when you control the scripts and they never change. But Next.js regenerates the hydration scripts every build, so you’d be redeploying a fresh hash list constantly.
script-src 'self' 'nonce-abc123' 'strict-dynamic';The right answer. A nonce is a fresh random token generated per request, stamped onto every legitimate inline script and listed in the CSP. The browser runs only scripts carrying the matching token; an injected script can’t know it, so it’s refused. This is what 'nonce-{NONCE}' stood in for in the baseline policy.
Pass 3: nonces and 'strict-dynamic'
Section titled “Pass 3: nonces and 'strict-dynamic'”A nonce resolves the conflict with a small round trip: the same token reaches three places on every request.
- Your request gate,
proxy.ts, generates one random nonce when a request arrives. - It writes that nonce into the
script-srcandstyle-srcof the CSP it sets on the response. - It also forwards the nonce to your app as the
x-noncerequest header. Next.js stamps it onto its own framework and page scripts automatically; you readx-nonceby hand only to mark an explicit<Script>or third-party tag.
The browser now sees 'nonce-abc123' in the CSP and nonce="abc123" on your scripts: they match, so they run.
An injected <script> carries no nonce, because the token is fresh every request and the attacker never saw it, so it’s refused.
One token remains: 'strict-dynamic' , what makes nonces practical.
A nonced bootstrap script loads other scripts: code chunks and third-party SDKs.
Without 'strict-dynamic' you’d list every one of those origins in script-src and keep the list current forever; with it, the browser trusts whatever a nonced script loads.
Trust propagates from the nonced root, and the CDN allowlist disappears.
The diagram walks one request through the round trip: watch the same token, abc123, appear at the gate, ride out on the CSP, ride back in on x-nonce, land on the script, and get checked.
A bare request hits proxy.ts — no token attached anywhere.
Buffer.from(crypto.randomUUID()).toString('base64')
→ abc123 (illustrative value)
Content-Security-Policy: … script-src 'self' 'nonce-abc123' …
x-nonce: abc123
const nonce = (await headers()).get('x-nonce') → abc123
<script nonce="abc123">…</script>
<script nonce="abc123">…</script>
✓ runs<script>stealCookies()</script>
✗ refusedYou won’t write this code: the starter ships proxy.ts complete, and your job is to recognize why each line is there.
export default function proxy(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}'`, `frame-ancestors 'none'`, ].join('; ');
const headers = new Headers(request.headers); headers.set('x-nonce', nonce);
const response = NextResponse.next({ request: { headers } }); response.headers.set('Content-Security-Policy', csp); return response;}A fresh nonce, per request. crypto.randomUUID() gives a random UUID, which Buffer.from(...).toString('base64') encodes as a compact token. It runs every request, which is exactly why CSP can’t live in build-time config: the value is never the same twice. This is the canonical Next.js idiom.
export default function proxy(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}'`, `frame-ancestors 'none'`, ].join('; ');
const headers = new Headers(request.headers); headers.set('x-nonce', nonce);
const response = NextResponse.next({ request: { headers } }); response.headers.set('Content-Security-Policy', csp); return response;}The nonce in the policy. The same token is interpolated into script-src and style-src. (Trimmed here to four directives; the real policy carries the full Pass 1 set.) This is the copy the browser checks scripts against.
export default function proxy(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}'`, `frame-ancestors 'none'`, ].join('; ');
const headers = new Headers(request.headers); headers.set('x-nonce', nonce);
const response = NextResponse.next({ request: { headers } }); response.headers.set('Content-Security-Policy', csp); return response;}The nonce forwarded to the app. A copy of the request’s headers is taken, x-nonce set on it, and passed forward via NextResponse.next({ request: { headers } }). Next.js stamps the nonce onto its own framework and page scripts automatically; a Server Component reads x-nonce with headers() to mark an explicit <Script> or third-party tag.
export default function proxy(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}'`, `frame-ancestors 'none'`, ].join('; ');
const headers = new Headers(request.headers); headers.set('x-nonce', nonce);
const response = NextResponse.next({ request: { headers } }); response.headers.set('Content-Security-Policy', csp); return response;}The policy on the response. Note the symmetry: x-nonce rides the request, inbound for your app to read, while the CSP rides the response, outbound for the browser to enforce. Same nonce, two directions.
Reconstruct the policy from understanding rather than memory. The block below has three values blanked, each encoding a real decision.
Fill each blank with the value that encodes the right decision — the floor, the analytics vendor, and the clickjacking rule. Pick the right option from each dropdown, then press Check.
default-src ___;script-src 'self' 'nonce-{NONCE}' 'strict-dynamic';img-src 'self' data: blob: https:;connect-src 'self' https://*.upstash.io https://*.sentry.io https://___;frame-ancestors ___;base-uri 'self';Why CSP lives in proxy.ts and the other five in next.config.ts
Section titled “Why CSP lives in proxy.ts and the other five in next.config.ts”One rule decides where each header goes:
A header with the same value on every response can be computed once at build time, in
next.config.ts. A header whose value changes per request must be built per request, inproxy.ts.
The five static headers never change, so they belong in build-time config.
CSP carries a fresh nonce on every response, so the proxy must assemble it per request.
On the routes the proxy matches, its CSP overrides any from next.config.ts, so set CSP in the proxy and nowhere else.
next.config.ts computed once at build · identical on every response Strict-Transport-SecurityX-Content-Type-OptionsReferrer-PolicyPermissions-PolicyX-Frame-Options
proxy.ts rebuilt per request · carries a fresh nonce -
Content-Security-Policywith the nonce
frame-ancestors is a directive inside this CSP — not a
separate header.
One split looks like a contradiction.
frame-ancestors is the modern clickjacking defense, but it’s a CSP directive, so it rides inside the proxy’s CSP string, while the legacy X-Frame-Options: DENY is a standalone header in next.config.ts.
The two clickjacking defenses land in different files, and that is the correct production shape.
Sort each header into the file that ships it — and remember a CSP directive travels inside the proxy's CSP string. Drag each item into the bucket it belongs to, then press Check.
Strict-Transport-SecurityX-Content-Type-OptionsReferrer-PolicyPermissions-PolicyX-Frame-OptionsContent-Security-Policyframe-ancestors 'none'The static-prerender trade-off and the marketing-site exception
Section titled “The static-prerender trade-off and the marketing-site exception”Every other header in this lesson is free; this one carries a cost, so meet it now rather than as a bug later.
A page that gets a fresh per-request nonce cannot be statically prerendered. Prerendering serves one shared HTML file to every visitor, but each request needs a different nonce, and you can’t bake a different value into a shared file.
For the app, that costs nothing. Every protected page already reads the session, the active org, and per-tenant data, so it was never a candidate for prerendering, and the nonce rides along for free.
It bites only on the page that genuinely wants to be static: your public marketing site example.com, as distinct from the app.example.com you’ve been hardening.
Marketing pages stay static for SEO and speed, so there you drop the nonce and ship a different shape of CSP, a nonceless policy with explicit origins that names every third party the pages load.
script-src 'self' 'nonce-{NONCE}' 'strict-dynamic';Nonce-based, renders per request. The protected app already reads session, org, and tenant data, so the per-request nonce is free. This is what proxy.ts ships for app.example.com.
script-src 'self' https://assets.calendly.com https://js.stripe.com;Nonceless, explicit origins, stays static. Without a nonce the page prerenders for SEO and speed, but every third-party origin must be listed by hand, here a scheduling embed and Stripe’s JS. With no nonce to vouch for your scripts, an omitted origin breaks that embed silently, so list every one.
The rule to carry: a nonce on the dynamic app shell, an explicit-origin CSP on the static marketing pages.
Ship Report-Only first, then enforce
Section titled “Ship Report-Only first, then enforce”A strict CSP has a sharp failure mode: flip it on in enforce mode, and one legitimate script you forgot to account for breaks the app in production for every user at once.
Roll it out in stages instead.
Ship the policy under the header name Content-Security-Policy-Report-Only first.
In that mode the browser reports every violation it would have blocked but blocks nothing, so the app keeps working while the reports tell you exactly what enforce mode would break.
Watch them for a week, fix the legitimate scripts the policy missed, then rename the header to Content-Security-Policy to enforce.
An error-tracking service can ingest these reports; wiring up that endpoint comes later.
Never ship Content-Security-Policy and Content-Security-Policy-Report-Only together with different policies: it confuses your tools and your own reasoning about which policy is in force.
Verify the headers on the live response
Section titled “Verify the headers on the live response”The browser is the only enforcer, and a malformed header fails silently, so verify the response rather than assume it shipped.
Run curl -I again, the command this lesson opened with, and read the same response now carrying the six headers.
The two tabs show before and after: the bare response from the introduction, then the hardened one from the same command.
$ curl -I https://app.example.comHTTP/2 200content-type: text/html; charset=utf-8cache-control: private, no-storeNothing for the browser to enforce. A correct response with zero security policy: frameable, sniffable, downgradable.
$ curl -I https://app.example.comHTTP/2 200content-type: text/html; charset=utf-8strict-transport-security: max-age=63072000; includeSubDomains; preloadx-content-type-options: nosniffreferrer-policy: strict-origin-when-cross-originpermissions-policy: camera=(), microphone=(), geolocation=(), payment=()x-frame-options: DENYcontent-security-policy: default-src 'self'; script-src 'self' 'nonce-abc123' 'strict-dynamic'; …Six rules the browser will now enforce. Same command, same app, every header present, the CSP carrying a live per-request nonce. A header is just a string on the response.
The second tool takes even less effort: paste your URL into securityheaders.com for a letter grade and a per-header breakdown, no setup.
The CI chapter turns this manual curl into a smoke test that asserts the six headers on every deploy, so a regression never reaches production.
For the full directive surface this lesson trimmed, these two are the canonical references:
Every CSP directive and value, with browser-support notes — the reference for the directives this lesson left off the baseline.
The framework's own guide to the per-request nonce in proxy.ts and reading it in Server Components — the pattern the starter ships.
Quick recall
Section titled “Quick recall”A teammate hits a wall of CSP violation errors in the console during development and asks to add 'unsafe-inline' to script-src “just to unblock myself for now.” Why is that the wrong fix?
'unsafe-inline' tells the browser to run any inline script, so an XSS-injected <script> runs right alongside the legitimate ones. The policy still loads — it just stops protecting anything. The right fix is a nonce, which marks the legitimate scripts so the injected one is still refused. “Temporarily” is how it ends up permanent.The next lesson turns from headers, which protect the browser, to rate limits, which protect your endpoints: which routes are abusable, and what makes a rate limiter mandatory rather than optional.
External resources
Section titled “External resources”Google's web.dev guide to the exact nonce + 'strict-dynamic' policy this lesson ships — why allowlists fail and nonces win.
The authoritative catalog of every security header with recommended values — the reference behind the baseline you just set.
Paste your deployed URL for a letter-grade, per-header report — the zero-setup verification the lesson points you to.