Skip to content
Chapter 81Lesson 1

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:

Terminal window
curl -I https://app.example.com
HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: private, no-store

This 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.

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.

Server computes a policy string
Response Content-Security-Policy: … Strict-Transport-Security: …
Browser policy enforced applies the rule to the page
The header is a one-way instruction. The server declares the policy on the response; the browser is the only thing that enforces it.

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.

TierHeaderWhat it tells the browser to refuse
Set onceStrict-Transport-SecurityTalking to this origin over plain http://
Set onceX-Content-Type-OptionsGuessing a response’s type and running it as script
Set onceReferrer-PolicyLeaking the full URL to other origins
Set oncePermissions-PolicyReaching for camera, mic, geolocation, payment
Set onceX-Frame-OptionsLetting old crawlers embed this page in a frame
Live attackContent-Security-PolicyRunning 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.

These five ship from one place, the headers() key in next.config.ts, because they are identical on every response.

next.config.ts
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.

1 / 1

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.

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.

1 / 1

Two lines carry the 'nonce-{NONCE}' tokens, the crux we have deferred. Here is why they must exist.

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.

A nonce resolves the conflict with a small round trip: the same token reaches three places on every request.

  1. Your request gate, proxy.ts, generates one random nonce when a request arrives.
  2. It writes that nonce into the script-src and style-src of the CSP it sets on the response.
  3. It also forwards the nonce to your app as the x-nonce request header. Next.js stamps it onto its own framework and page scripts automatically; you read x-nonce by 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.

incoming Request
request gate proxy.ts
renders Server Component
enforces Browser
at the gate

A bare request hits proxy.ts — no token attached anywhere.

A request arrives at the proxy. No nonce exists yet.
incoming Request
request gate proxy.ts nonce = abc123
renders Server Component
enforces Browser
one fresh token, per request

Buffer.from(crypto.randomUUID()).toString('base64')

abc123 (illustrative value)

The proxy generates one random nonce for this request.
incoming Request
request gate proxy.ts nonce = abc123
renders Server Component
enforces Browser
same token, two directions
response header → browser

Content-Security-Policy: … script-src 'self' 'nonce-abc123' …

request header → app

x-nonce: abc123

The proxy sets two things: the nonce inside the response CSP, and a forwarded x-nonce request header.
incoming Request
request gate proxy.ts
renders Server Component
enforces Browser
reads the token, stamps the script

const nonce = (await headers()).get('x-nonce') → abc123

rendered markup

<script nonce="abc123">…</script>

A Server Component reads x-nonce via headers() and stamps it on the inline scripts it renders.
incoming Request
request gate proxy.ts
renders Server Component
enforces Browser
CSP lists 'nonce-abc123' — match or refuse

<script nonce="abc123">…</script>

✓ runs

<script>stealCookies()</script>

✗ refused
The browser compares. Nonce matches → the script runs. An injected script with no nonce → refused.

You 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.

1 / 1

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, in proxy.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-Security
  • X-Content-Type-Options
  • Referrer-Policy
  • Permissions-Policy
  • X-Frame-Options
proxy.ts rebuilt per request · carries a fresh nonce
  • Content-Security-Policy with the nonce

frame-ancestors is a directive inside this CSP — not a separate header.

Constant headers ship from build-time config; the per-request CSP ships from the proxy because its nonce is regenerated on every request.

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.

next.config.ts Constant, built once
proxy.ts Per-request, carries the nonce
Strict-Transport-Security
X-Content-Type-Options
Referrer-Policy
Permissions-Policy
X-Frame-Options
Content-Security-Policy
frame-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.

The rule to carry: a nonce on the dynamic app shell, an explicit-origin CSP on the static marketing pages.

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.

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.com
HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: private, no-store

Nothing for the browser to enforce. A correct response with zero security policy: frameable, sniffable, downgradable.

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:

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?

It measurably slows down hydration on every page load.
It’s deprecated and modern browsers ignore it.
It re-allows every inline script — including an attacker’s injected one — which is exactly the hole CSP exists to close.
It only works in Report-Only mode, so it does nothing in production.

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.