Skip to content
Chapter 81Lesson 1

Headers that block live attacks

The HTTP security headers that open the course's pre-launch hardening unit, the browser-enforced response rules, including a nonce-based Content-Security-Policy, that a 2026 SaaS ships 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

Everything here is correct, and that is exactly the problem. The app boots, the page renders, and the response is well-formed. Yet 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. None of that happens because something is broken. It happens because you never told the browser to refuse.

That is what this lesson covers. A handful of response headers, most of them a single line you set once and never touch again, close clickjacking, MIME-sniffing, protocol downgrade, and third-party script injection. One idea makes them stop feeling like magic incantations: a security header is a rule the browser enforces, and the server’s only job is to ship the rule. You are not adding protection to the server. You are telling the browser what to refuse on the response it already received.

You have met both halves of this before. You first saw security headers as a category when you learned what HTTP headers carry, and you were told there that the full set, CSP especially, belonged to the security-baseline chapter. This is that chapter. You have also already shipped the two files this lesson gives a security job: the headers() key in next.config.ts, and proxy.ts, your request gate. Each one now gets one new responsibility. By the end you will be able to name the six headers, say which file ships each and why, read a Content-Security-Policy line directive by directive, and run that same curl -I to confirm the whole thing in under a minute.

Before any specific header, pin down the one idea every header is an instance of. Once it clicks, the rest is a checklist.

A response carries two things. The body holds the content: the HTML, the JSON. The headers form a separate channel of metadata that describes the body and instructs the client about it. A security header lives in that metadata channel, and what it instructs is the browser: which scripts this page is allowed to run, whether this page may be displayed inside a frame, whether the browser must use HTTPS the next time it talks to this origin. The server computes a policy string and attaches it. The browser is the engine that reads the string and applies it to the page it just loaded.

The server enforces nothing at runtime. It does not inspect each script as it executes, block the frame, or upgrade the connection. It declares a rule and walks away, and the browser does all the work.

In the diagram below, follow the single arrow. The server emits a response with a header attached, which is the policy declared. The browser receives it and applies the rule to the page, which is the policy enforced. The instruction only ever flows one way.

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 from this, and both matter for how you reason about these headers.

First, they are cheap. Emitting a header costs the server nothing beyond writing a string: no per-request inspection, no CPU spent enforcing. So the common instinct that “more security must cost performance” is wrong here. The cost is zero, which means there is no trade to make on the five static ones.

Second, they fail silently. The browser is the only enforcer, so there is no server-side backstop. If the header is malformed, or the visitor is on a browser too old to understand it, the rule simply does not apply, and nothing tells you. This is why the verification step that closes the lesson is not optional. “I shipped the header” and “the browser is enforcing the header” are two different claims, and only the second one protects anyone.

Here is the map for the rest of the lesson. The baseline has six headers, and they fall into two tiers that you should 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 |

This split is the whole strategy. Five of these are configuration-grade hardening: one decision each, made once, never revisited. The sixth, CSP , is the only one that intercepts a running attack, and the only one that needs real thought. Spend your attention there and treat the other five as a list to tick off. We will take the five first, so they are out of the way.

Two terms come up repeatedly. Clickjacking is when an attacker loads your page invisibly inside theirs and tricks a user into clicking it. MIME-sniffing is when the browser second-guesses a response’s declared type and runs something it shouldn’t.

These five ship together from one place, the headers() key in next.config.ts, because they are identical on every response your app ever sends. Here is the array. Read it once for shape, and we will walk it line by line right after.

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 these to every route. Now take each header in turn: what it refuses, the value the course ships, and the single knob that ever moves.

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, but only in production. max-age=63072000 is two years. Once a browser sees this header, it refuses plain http:// to this origin for that long, which closes SSL-strip downgrade attacks after the first secure visit. includeSubDomains extends the rule to every subdomain, and preload opts into the browser-shipped preload list. The gate that matters is to emit it only in production. On localhost it would lock http://localhost to HTTPS and break your dev server, which is what the isProd branch prevents. (Submitting to the list at hstspreload.org is an optional follow-up, not something this config does.)

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: no decision to make. It stops the browser from MIME-sniffing a response and executing a non-script, such as an uploaded file or a text response, as JavaScript. There is no value to tune and no downside. Ship it and forget it.

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 sensible default, set explicitly. It sends only the origin, not the full path, on cross-origin navigations, and the full path on same-origin ones. This is already the modern browser default, and you set it so it isn’t left to chance. Watch the tempting alternative: no-referrer looks more secure but breaks legitimate analytics attribution. The experienced pick is the balanced one, not the most locked-down one.

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 entry turns that browser capability off for the whole page, so an injected script can’t quietly reach for the camera, mic, geolocation, or Payment Request API. The one entry that ever changes: the day you ship Stripe Elements, payment=() becomes payment=(self "https://js.stripe.com"). Deny everything you don’t use, and open exactly what you do.

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 that no site may frame this page. CSP’s frame-ancestors 'none' (coming up) does the same job and more, so this header is redundant on purpose. You keep it only for old crawlers and bots that don’t parse CSP, giving you two layers of the same protection.

1 / 1

That is five headers and exactly two decisions that ever change: turn HSTS off outside production, and open Permissions-Policy for Stripe if and when Stripe arrives. Everything else is a constant. That is why grouping them as a block is the right move. Bundling them signals “checklist,” which is the posture they deserve, and it clears the deck so the one header that isn’t a checklist item gets your full attention.

Two terms for this section. HSTS is the header itself, and 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”

Now the one that matters. The five above harden configuration: they close doors that should never have been open. 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 field, a profile bio, or a support ticket, anywhere user input reaches the DOM, has achieved XSS , and that script runs with your user’s full session. CSP is the layer that says the script may be on the page, but it isn’t on the allowlist, so the browser refuses to run it. The same idea covers connections: a script that tries to fetch stolen data to evil.example hits a wall, because that origin isn’t on the list of allowed connections.

CSP is a per-page allowlist of where code and connections may come from. Anything not on the list is denied by the browser at runtime. It is also the hard part of this lesson, so we build it in three passes: first the directive list, then the problem that forces nonces, then nonces themselves.

Here is the 2026 baseline policy for this stack. Read it as a deny-by-default list, where each line names one kind of resource and the small set of places it may legitimately come from. Everything else is refused.

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 that doesn’t have its own line, and 'self' means same-origin only. Every directive below either narrows or widens this baseline for one specific kind of 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. Read 'nonce-{NONCE}' and 'strict-dynamic' as placeholders for now, since Pass 3 explains them. The point today is that scripts must be same-origin or carry an explicit mark of trust. There is no blanket permission for arbitrary inline script, and that is the whole game.

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: every third party justifies itself. connect-src controls where fetch, WebSocket, and sendBeacon may reach. Three vendors are listed: *.upstash.io (rate limiting), *.sentry.io (error monitoring), and us.i.posthog.com (analytics). An entry here means this app deliberately talks to this vendor, so an entry nobody can explain is something to investigate. The policy is a manifest of who your app trusts.

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 user uploads or a CDN. That looks permissive, and it is acceptable, because images don’t execute. The danger CSP guards hardest against is code running, and a wide image policy doesn’t open that door.

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' says that no site, anywhere, may embed this page in a frame. This is the modern replacement for X-Frame-Options: DENY: the same protection, but understood by current browsers as part of the policy rather than as a separate legacy 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. Both are cheap lines that close real holes.

1 / 1

Read top to bottom, the policy is a sentence: default to same-origin; scripts and styles need an explicit mark; talk only to these three vendors; images can come from anywhere over HTTPS; nobody may frame us; nobody may rewrite our base or hijack our form posts. Two of those lines carry the 'nonce-{NONCE}' tokens we have been deferring. They are the crux, so here is why they have to exist.

A strict script-src 'self' blocks all inline script: any <script> with code directly between the tags, rather than a src pointing at a same-origin file. That is the right default, because inline script is exactly the shape an XSS injection takes. But it collides head-on with how your framework works.

Next.js injects inline <script> tags to bootstrap hydration, and React Server Components stream inline scripts as part of rendering. These are legitimate scripts your app cannot run without, so a blanket “no inline script” policy blocks your own framework’s hydration and leaves you with a dead, non-interactive page.

There are exactly three ways to let the legitimate inline scripts through, and two of them are wrong. The three tabs below lay that choice out side by side.

script-src 'self' 'unsafe-inline';

The trap, and not even “temporarily.” 'unsafe-inline' allows every inline script, including the attacker’s. It re-opens the precise hole CSP exists to close, so a policy carrying it provides almost no XSS protection at all. This is the single most common real-world CSP mistake: a developer hits CSP errors, pastes in 'unsafe-inline' to make them stop, and ships a policy that does nothing.

A nonce , short for “number used once,” is the resolution. The mechanism is a small round trip, and it touches three places with the same token on every single request:

  1. Your request gate, proxy.ts, generates one random nonce when a request arrives.
  2. It puts 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 a request header (x-nonce), so your Server Components can read it, via the same headers() function you use to read any incoming header, and stamp it onto the scripts they render. Next.js does this automatically for its own framework and page scripts, reading the nonce straight off the CSP header. You only reach for x-nonce by hand to mark an explicit <Script> or third-party tag.

When the browser gets the page, it sees the CSP listing 'nonce-abc123', and it sees your legitimate scripts carrying nonce="abc123". They match, so they run. An attacker’s injected <script> has no nonce, because the token is fresh every request and the attacker never saw it. No match, so it’s refused.

That leaves one token in the policy still unexplained: 'strict-dynamic' . It is the multiplier that makes nonces practical. A modern app’s nonced bootstrap script loads other scripts: code chunks and third-party SDKs. Without 'strict-dynamic', you’d have to list every one of those origins in script-src by hand and keep the list current forever. With it, the browser reasons that this script was already trusted because it had the nonce, so it trusts the scripts that script chooses to load too. Trust propagates outward from the nonced root, and you stop maintaining a giant CDN allowlist.

The diagram below walks one request through the whole round trip. Scrub it step by step and 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 finally get checked by the browser.

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.

The diagram is built to make one fact visual: it is one token, in three places, regenerated every request. Holding that makes the trade-off in the next section obvious instead of something you have to take on faith.

Now for the proxy code that does all of this. You will not write it from a blank file. The project starter ships proxy.ts complete, and your job in the audit is to read it and know why each line is there, not to author the nonce machinery yourself. So read the following for recognition: when you open the starter, this is the shape you’ll see, and here is what every piece does.

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, and Buffer.from(...).toString('base64') encodes it as a compact token. This runs on every request, which is the entire reason CSP can’t live in build-time config: the value is never the same twice. This exact expression 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 injected into the policy. The same token is interpolated into script-src and style-src. (The policy shown is trimmed to four directives; the real one carries the full Pass 1 set.) This is the copy of the nonce the browser will check incoming 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 is set on it, and that modified header set is passed forward via NextResponse.next({ request: { headers } }). Next.js stamps the nonce onto its own framework and page scripts automatically. x-nonce is what a Server Component reads with headers() to mark an explicit <Script> or third-party tag by hand.

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 set on the response. Note the symmetry: x-nonce goes on the request, inbound for your app to read, while the CSP goes on the response, outbound for the browser to enforce. Same nonce, two directions, exactly the round trip the diagram walked.

1 / 1

Try reconstructing the policy from understanding rather than memory. In the exercise below, the CSP block has three values blanked, and each one encodes 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”

You have now seen both files do their job. The principle that decides which header goes where fits in one sentence:

A header that is identical 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.

That is the entire reasoning, and once you have it, the file assignment falls out. The five static headers are constant: HSTS is always the same string, nosniff is always nosniff, so they belong in build-time config. CSP carries a fresh nonce on every request, so it cannot be precomputed and has to be assembled in the proxy, which runs on each request. This is why the chapter needs both files to have a security job, and it is the architectural decision the whole lesson has been building toward.

One precedence note so you don’t trip over it: for the routes the proxy matches, the CSP it sets on the response overrides any CSP coming from next.config.ts. So you set CSP in the proxy and nowhere else, because you don’t want two CSP sources fighting each other. The five static headers come from config, and the proxy never touches them. One header, one home.

The figure below is the one-glance version, with each header mapped to its file and the reason.

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 wrinkle in that split is worth stating plainly, because it looks like a contradiction until you see it. frame-ancestors is the modern clickjacking defense, but it is a CSP directive, so it physically rides inside the proxy’s CSP string, not in next.config.ts. The legacy X-Frame-Options: DENY is its own standalone header and does live in next.config.ts. So the two clickjacking defenses sit in different files: the real one as a directive in the proxy, the legacy fallback as a header in config. That is the correct production shape, not an oversight.

The exercise below is the cleanest check of the lesson’s central distinction. Sort each header into the file that ships it. Watch the two clickjacking defenses in particular, because they go to different files, and knowing why is the point.

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 costs nothing. This one costs something, and it is the only real trade-off, so name it now, because the alternative is discovering it as a bug.

A page that gets a fresh per-request nonce cannot be statically prerendered. Prerendering means the page is rendered once at build and the same HTML is served to everyone. But every request needs a different nonce, and you can’t bake a different value into one shared static file. So a nonced page must render dynamically, per request.

For the app itself, that costs you nothing, because every protected page in your app is already dynamic. It reads the session, the active org, and per-tenant data, so it was never a candidate for static prerendering in the first place. The nonce rides along for free on pages that were always going to render per request anyway.

The place it does bite is the one kind of page that genuinely wants to be static: your public marketing site, example.com, as distinct from the app.example.com we’ve been hardening. Marketing pages are static for SEO and speed, and you don’t want to force them dynamic just to attach a nonce. So there, you opt out of nonces and ship a different shape of CSP, a nonceless policy with explicit origins, where you list every third party the marketing pages load by name.

script-src 'self' 'nonce-{NONCE}' 'strict-dynamic';

Nonce-based, renders per request. The protected app is already dynamic, reading 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, and an explicit-origin CSP on the static marketing pages. One warning for the marketing side: because no nonce is there to trust your scripts, a too-strict policy that omits a third-party origin will silently break that embed. Every origin the page loads must be in the list.

A strict CSP has a failure mode that has burned plenty of teams: you flip it on in enforce mode, it blocks a legitimate script you forgot to account for, and now it’s broken in production, for every user, all at once. The experienced move is to never enforce a brand-new CSP blind.

The safe rollout has a built-in dress rehearsal. 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. Your app keeps working exactly as before, while a stream of violation reports tells you precisely what a real enforce-mode policy would break. You watch that stream for a week, fix the legitimate scripts the policy didn’t account for, and only then rename the header to Content-Security-Policy to actually enforce. (Sentry ingests these violation reports natively. Wiring that endpoint up is the observability chapter’s job, not this one; for now just know the reports have somewhere to go.)

One tooling trap to avoid during the rollout: do not ship Content-Security-Policy and Content-Security-Policy-Report-Only at the same time with different policies. It confuses tools and, more importantly, your own reasoning about which policy is actually in force. While you roll out, it’s one or the other, never both.

“I shipped the headers” is an unverified claim until you’ve looked at the response, and you already know why: the browser is the only enforcer, and a malformed header fails silently. Two tools close the loop, both fast.

The first is the command this lesson opened with. Run curl -I again and read the same response, except that now it carries the six headers. The two tabs below show the before and after: the bare response from the introduction, and the hardened one, same command, transformed output.

$ 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 is even less effort: paste your URL into securityheaders.com and it returns a letter grade with a per-header breakdown, no setup at all. Use it as the zero-friction external sanity check.

One forward pointer so you know where this habit goes next. Turning this manual curl into an automated check, a CI smoke test that asserts the six headers are present on every deploy so a regression never reaches production, lands in the CI chapter later. Here you verify by hand; there you make it impossible to forget.

For the full directive surface this lesson deliberately trimmed, these two are the canonical references:

Two checks on the load-bearing ideas. Reason them through rather than matching the prose.

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.

HSTS, nosniff, and Referrer-Policy all ship from next.config.ts, but CSP ships from proxy.ts instead. What’s the reason for the split?

next.config.ts can’t set a header as long as a CSP string.
CSP carries a nonce that’s regenerated per request, so it can’t be the single constant value a build-time header is.
The proxy runs earlier, so CSP is enforced sooner than the other headers.
next.config.ts headers don’t apply to dynamically rendered pages.

You can now answer, against your own deploy, the question this lesson set out to address: name the six headers, say which file ships each and why, read the CSP line by line, and confirm the whole set with one curl -I. The next lesson moves from the headers that protect the browser to the limits that protect your endpoints: which routes are abusable, and what makes a rate limiter mandatory rather than optional.