Security headers and Content Security Policy
Send security headers from next.config.ts, add a Content Security Policy with a nonce from proxy.ts, taint secret data, and keep your dependencies honest.
- 01Send security headersAdd headers like Strict-Transport-Security and X-Frame-Options to every response from next.config.ts, and see the browser obey them.
- 02Add a policy with a nonceMake a nonce in proxy.ts, put it in a Content Security Policy, and know why every page must then be made for each request.
- 03Keep secrets and packages in checkTaint data that must never reach a Client Component, and use a lockfile, npm ci and npm audit for the code you install.
Rules that travel with every response
A browser follows rules that a site sends it. "Only ever load me over HTTPS." "Never show me inside another site's frame." "Run only the scripts I wrote." Each rule travels as a response header: a line of text, a name and a value, that the server sends before the page itself. The headers whose job is to turn on such rules are called security headers. The browser reads them and enforces them for every visitor, so one line on the server protects everyone.
Next.js lets you list headers in next.config.ts. Here is the tea shop with five security headers. It was built with next build and run with next start from Next.js 16.4.3, and what it sent back was recorded.
next.config.tsimport type { NextConfig } from "next"; const securityHeaders = [ { key: "Strict-Transport-Security", value: "max-age=86400" }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "X-Frame-Options", value: "DENY" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },]; const nextConfig: NextConfig = { /* config options here */ cacheComponents: true, partialPrefetching: true, poweredByHeader: false, async headers() { return [{ source: "/(.*)", headers: securityHeaders }]; },}; export default nextConfig;GET /200
- Response headers
Strict-Transport-Securitymax-age=86400X-Content-Type-OptionsnosniffX-Frame-OptionsDENYReferrer-Policystrict-origin-when-cross-originPermissions-Policycamera=(), microphone=(), geolocation=()Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300Cache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 14 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<title>Tea shop</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<main>
<h1>Tea shop</h1>
<p>Fresh tea, sent every week.</p>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>Lines 3 to 9 of next.config.ts list the headers, each a key and a value. Lines 16 to 18 add an async headers() function, which Static files used to give a logo a longer cache. It returns one rule: for every path, /(.*), send all five. Line 15, poweredByHeader: false, removes a header Next.js adds on its own.
Open the Network tab. The home page, /, came with all five, and so did /logo.svg, a file from public: the pattern matches every path, not only pages. Look at the other headers of / too: x-nextjs-cache: HIT and Cache-Control: s-maxage=31536000. The page is still prerendered, as ○ / in the Terminal tab says. Next.js adds these headers when it sends a response; they don't change how the page is made.
Then open the Steps tab. Real Chrome opened /partners, a page of the same shop that shows the home page in an <iframe>. Chrome refused, and its console said why: Refused to display '…/' in a frame because it set 'X-Frame-Options' to 'deny'. The server sent a rule, and the browser enforced it.
next.config.ts adds the five headers to every response. The browser reads each one and enforces its rule for the visitor: in the recording, Chrome refused to show the home page in a frame.A concert venue prints its house rules on every ticket. The venue doesn't stand next to each guest; the door staff read the ticket and enforce what it says. Some rules are the same every night, like "no cameras". Others change: the band list, and a wristband color that's new every night, so last week's wristband gets nobody in.
- In real life: The venue prints its house rules on every ticket
- In Next.js: Next.js adds security headers to every response
- In real life: The door staff read the ticket and enforce the rules
- In Next.js: The browser reads the headers and obeys them
- In real life: No cameras inside
- In Next.js:
Permissions-Policy: camera=() - In real life: Only the people on tonight's list may go on stage
- In Next.js: A Content Security Policy: only scripts with the nonce run
- In real life: A new wristband color every night
- In Next.js: A new nonce for every request
Security headers are rules the server sends with each response, and the browser enforces. Rules that never change go in next.config.ts. A Content Security Policy with a nonce changes with every request, so proxy.ts makes it, and every page must then be made for each request. Two more protections work on the server: taint, which stops chosen data from reaching the browser, and care with the packages you install.
Five headers and what they stop
Each row is one header from the shop's next.config.ts: what it tells the browser, and what it stops.
| Header | What it tells the browser | What it stops |
|---|---|---|
Strict-Transport-Security | Use only HTTPS for this site for the next day (start short, then raise it). | A visitor on a café's network being sent to a fake http:// copy of the shop. Browsers ignore it on a page sent over plain HTTP. |
X-Content-Type-Options: nosniff | Trust the Content-Type header; don't guess a file's type from its contents. | An uploaded file that looks like a script being run as one. |
X-Frame-Options: DENY | Never show this page inside a frame on any page. | Another site showing the shop in a hidden frame and tricking visitors into clicking its buttons. |
Referrer-Policy: strict-origin-when-cross-origin | Tell other sites only which site a visitor came from, not the full address. | Order numbers or search words in an address leaking to other sites. |
Permissions-Policy: camera=(), … | This page and its frames may never use the camera, the microphone or the location. | A script someone slipped in asking the visitor for their camera. |
A few details matter when you copy these into your own app:
Strict-Transport-Security, often called HSTS, counts in seconds:86400is one day. Once a browser has seen it, it refuses plain HTTP for the shop until that time runs out, even if HTTPS breaks. So roll it out in steps: start short, like this one day, check that every page works over HTTPS, then raise it, up to a year or two (the docs show63072000, two years).- Two more words you'll see in HSTS values.
includeSubDomainscovers every subdomain, the ones you have now and any you add later, likeblog.in front of the shop's address; add it only when all of them will serve HTTPS.preloadonly says the site is eligible for a list built into browsers, so even a first visit uses HTTPS. Getting on that list takes a separate submission, at hstspreload.org, and getting off it is slow, so leave it out until you're sure. - Browsers ignore HSTS on a page sent over plain HTTP, like the recordings here, which ran on
http://127.0.0.1. X-Frame-Optionsis the older way to say "no frames". Next.js's docs say a Content Security Policy'sframe-ancestorshas replaced it, and the shop's policy below has it too. Sending both does no harm.Referrer-Policy: strict-origin-when-cross-originis what current browsers already do when a site says nothing. Sending it makes it the rule in every browser.
Pick each version and compare the Network and Steps tabs:
next.config.tsimport type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ cacheComponents: true, partialPrefetching: true,}; export default nextConfig;GET /200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 14 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<title>Tea shop</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<main>
<h1>Tea shop</h1>
<p>Fresh tea, sent every week.</p>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>No security headers at all: the page comes with X-Powered-By: Next.js, and Chrome shows the home page inside the partners page's frame without a word.
Without the headers, Chrome shows the home page inside the frame, and logs nothing. Every response also says X-Powered-By: Next.js, which tells anyone who asks which framework the shop runs. poweredByHeader: false removes it, and the second version's responses don't have it. That makes the shop a little less chatty, not safer: a Next.js app is still easy to recognize from its /_next/ files and its HTML, so the protection is keeping Next.js up to date.
headers() in next.config.ts returns a fixed list: every request that matches gets the same values. A header that depends on the request, like a policy with a new nonce, is set in proxy.ts, which runs for each request.
A Content Security Policy: which scripts may run
The mint tea page shows what customers wrote about it. The reviews were posted as HTML, and the page puts that HTML in as it is. One customer hid a script in theirs. This is cross-site scripting, or XSS: someone gets their script into your page, and it runs in every visitor's browser with the page's rights. It can read what the page shows, change it, and send requests as the visitor. (XSS and safe DOM updates shows how it gets in.)
app/teas/mint/page.tsximport { reviews } from "../../../lib/reviews";import RunLog from "../../run-log"; export default function MintPage() { return ( <main> <h1>Mint tea</h1> <h2>Reviews</h2> {reviews.map((review) => ( <div key={review.id} dangerouslySetInnerHTML={{ __html: review.html }} /> ))} <RunLog /> </main> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Fresh and sweet. I drink it every morning.
Lovely!
Scripts that ran: the shop script, the hidden review script
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (1), without the files it loaded
GET/teas/mint200a whole page
Line 10 of app/teas/mint/page.tsx puts each review's HTML into the page with dangerouslySetInnerHTML. React normally shows a string as text, so a <script> inside one would appear on the page as letters. dangerouslySetInnerHTML skips that, as its name warns. Line 6 of lib/reviews.ts is the second review: Lovely!, then a <script> that writes down that it ran.
Two files help to see what ran. app/shop-script.tsx is the shop's own inline script, which also writes down that it ran. app/run-log.tsx is a Client Component: once React starts, its Effect on lines 14 to 19 shows what was written down. Before React starts, line 21 says React has not started, so nothing is listed.
The Steps tab shows the page in real Chrome: Scripts that ran: the shop script, the hidden review script. The first fix belongs in the page: don't put HTML you didn't write into it, or clean it with a sanitizer first. A policy is the second wall, for the day a bug slips through.
A Content Security Policy, or CSP, is a header that lists where each kind of content may come from. Its rules are called directives. With a policy like the shop's, the browser blocks every inline script, a script written inside the page, unless the policy allows it. The usual way to allow one is a nonce: a random value, new for every response, that the policy names and each allowed script carries as nonce="…". A script someone hides in a review can't carry it, because nobody knows the value before the response is made.
Each row is one directive of the policy the shop sends next, and what it allows:
| Directive | What it allows |
|---|---|
default-src 'self' | Anything the other rules don't name may come only from the shop's own address. |
script-src 'self' 'nonce-…' 'strict-dynamic' | A script runs only if it carries this response's nonce, or a script that does added it. |
style-src 'self' 'nonce-…' | Stylesheets come from the shop, and a <style> element needs the nonce. |
object-src 'none' | No old plugin content, like Flash, at all. |
base-uri 'self' | A <base> element someone slips in can't change where the page's links point. |
frame-ancestors 'none' | No page may show this one in a frame: the newer form of X-Frame-Options: DENY. |
A nonce for every request, made in proxy.ts
The nonce has to be new for every request, and known before the page is rendered. proxy.ts runs for each request, before its route, so it's the place to make it. Here is the shop with a policy:
proxy.tsimport { NextResponse, type NextRequest } from "next/server"; export function proxy(request: NextRequest) { const nonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64"); const policy = [ "default-src 'self'", `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`, `style-src 'self' 'nonce-${nonce}'`, "object-src 'none'", "base-uri 'self'", "frame-ancestors 'none'", ].join("; "); const headers = new Headers(request.headers); headers.set("x-nonce", nonce); headers.set("Content-Security-Policy", policy); const response = NextResponse.next({ request: { headers } }); response.headers.set("Content-Security-Policy", policy); return response;} export const config = { matcher: [ { source: "/((?!api|_next/static|_next/image|favicon.ico).*)", missing: [ { type: "header", key: "next-router-prefetch" }, { type: "header", key: "purpose", value: "prefetch" }, ], }, ],};Each step was taken in real Chrome against next start. Pick one to see the page after it.
Fresh and sweet. I drink it every morning.
Lovely!
Scripts that ran: the shop script
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Errors in Chrome's console during this step (1)
Executing inline script violates the following Content Security Policy directive 'script-src 'self' 'nonce-h/HDDim6sJnw7cgxcRUq7g==' 'strict-dynamic''. Either the 'unsafe-inline' keyword, a hash ('sha256-I6X5dizqk7FawZCqhGkR6WzwoE7egVd5g/bqSCvEQ8c='), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.
Requests the page made during this step (1), without the files it loaded
GET/teas/mint200a whole page
Line 4 of proxy.ts makes the nonce from 16 random bytes, from crypto.getRandomValues, written in base64. (Next.js's docs base64-encode crypto.randomUUID(), which has 122 random bits; base64 only changes how they're written, not how hard they are to guess.) Lines 5 to 12 build the policy, one directive per line. Line 7 names this nonce in script-src. Lines 14 to 16 copy the request's headers and add two: x-nonce, for your own code, and the policy itself, for Next.js. Line 18 passes them on to the route, the way proxy.ts taught. Line 19 sends the policy to the browser. The matcher on lines 23 to 33 is the docs': it skips Next.js's own files and the router's prefetches, which aren't pages.
In app/layout.tsx, line 8 reads the nonce with headers() (Request-time APIs), and line 12 gives it to the shop's script. Next.js does the rest: it finds the nonce in the request's Content-Security-Policy header and adds it to every script it writes. Open the Network tab: each <script> has nonce="…" with the value the response's policy names, except the review's.
The Steps tab shows what Chrome did. Its console logged one error, Executing inline script violates the following Content Security Policy directive…, and the page says Scripts that ran: the shop script. The review's script was blocked; everything else ran, and React started.
N1 stands for the random value, new for every request. proxy.ts puts it in the policy; Next.js puts it on every script it writes, and the layout gives it to the shop script. The browser runs the scripts whose nonce matches the policy's, and blocks the review's, which has none.Step through one visit with no policy, then with the nonce, and watch who decides which script runs:
No policyafter step 1A blank page
React isn't running yet: clicks do nothing.
The page isn't complete yet.
The browser asks the shop for the mint tea page.
Next.js reads the nonce from the request's policy header, the one line 16 sets. headers.set replaces any header with that name the visitor sent. In May 2026, the advisory GHSA-ffhc-5mcf-pf4q showed that Next.js 16 before 16.2.5 could put a badly formed nonce from a visitor's request into the HTML. Next.js 16.4.3 ignores a nonce made of anything but letters, digits and + / _ -, with up to two = at the end. Still, let proxy.ts set the header for every page.
A nonce needs a page made for each request
A nonce is new for every request. But the prerendered page, ○, in this recording had its HTML generated and saved during next build, before any request, and its nonce, existed. (It can be generated again later, but never for one request.) Its <script> tags have no nonce, so the browser blocks them. What about a static shell, ◐? Each button below is one version of the mint tea page. The first three use the same proxy.ts; the last two send a fixed policy from next.config.ts instead. Guess first: in which versions does React start?
app/layout.tsximport { ShopScript } from "./shop-script"; export const metadata = { title: "Tea shop" }; export default function RootLayout({ children }: LayoutProps<"/">) { return ( <html lang="en"> <body> <ShopScript /> {children} </body> </html> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Fresh and sweet. I drink it every morning.
Lovely!
React has not started, so nothing is listed.
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Errors in Chrome's console during this step (17)
Loading the script 'http://127.0.0.1:60996/_next/static/chunks/3cfn99dbrcvvp.js' violates the following Content Security Policy directive: "script-src 'self' 'nonce-ktjBSpSNvfbPZ2egQl4Afg==' 'strict-dynamic'". Note that 'strict-dynamic' is present, so host-based allowlisting is disabled. Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback. The action has been blocked.Loading the script 'http://127.0.0.1:60996/_next/static/chunks/1j_b1xq_woyiy.js' violates the following Content Security Policy directive: "script-src 'self' 'nonce-ktjBSpSNvfbPZ2egQl4Afg==' 'strict-dynamic'". Note that 'strict-dynamic' is present, so host-based allowlisting is disabled. Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback. The action has been blocked.Loading the script 'http://127.0.0.1:60996/_next/static/chunks/2ki52vdnbo2ft.js' violates the following Content Security Policy directive: "script-src 'self' 'nonce-ktjBSpSNvfbPZ2egQl4Afg==' 'strict-dynamic'". Note that 'strict-dynamic' is present, so host-based allowlisting is disabled. Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback. The action has been blocked.Loading the script 'http://127.0.0.1:60996/_next/static/chunks/turbopack-3-ry682tkyiqk.js' violates the following Content Security Policy directive: "script-src 'self' 'nonce-ktjBSpSNvfbPZ2egQl4Afg==' 'strict-dynamic'". Note that 'strict-dynamic' is present, so host-based allowlisting is disabled. Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback. The action has been blocked.Loading the script 'http://127.0.0.1:60996/_next/static/chunks/1tq6ppe__m8m-.js' violates the following Content Security Policy directive: "script-src 'self' 'nonce-ktjBSpSNvfbPZ2egQl4Afg==' 'strict-dynamic'". Note that 'strict-dynamic' is present, so host-based allowlisting is disabled. Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback. The action has been blocked.Loading the script 'http://127.0.0.1:60996/_next/static/chunks/2j8hy4o9oe4-c.js' violates the following Content Security Policy directive: "script-src 'self' 'nonce-ktjBSpSNvfbPZ2egQl4Afg==' 'strict-dynamic'". Note that 'strict-dynamic' is present, so host-based allowlisting is disabled. Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback. The action has been blocked.Executing inline script violates the following Content Security Policy directive 'script-src 'self' 'nonce-ktjBSpSNvfbPZ2egQl4Afg==' 'strict-dynamic''. Either the 'unsafe-inline' keyword, a hash ('sha256-w8LlHI3DZwXShU+H276LZ73NjS+yzCkp//qWPFljT0I='), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.Executing inline script violates the following Content Security Policy directive 'script-src 'self' 'nonce-ktjBSpSNvfbPZ2egQl4Afg==' 'strict-dynamic''. Either the 'unsafe-inline' keyword, a hash ('sha256-I6X5dizqk7FawZCqhGkR6WzwoE7egVd5g/bqSCvEQ8c='), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.Executing inline script violates the following Content Security Policy directive 'script-src 'self' 'nonce-ktjBSpSNvfbPZ2egQl4Afg==' 'strict-dynamic''. Either the 'unsafe-inline' keyword, a hash ('sha256-J4XthOqUA44JpfFNsixxGYrgW6rBSFSIOfh6puY2Apc='), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.Loading the script 'http://127.0.0.1:60996/_next/static/chunks/3cfn99dbrcvvp.js' violates the following Content Security Policy directive: "script-src 'self' 'nonce-ktjBSpSNvfbPZ2egQl4Afg==' 'strict-dynamic'". Note that 'strict-dynamic' is present, so host-based allowlisting is disabled. Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback. The action has been blocked.Executing inline script violates the following Content Security Policy directive 'script-src 'self' 'nonce-ktjBSpSNvfbPZ2egQl4Afg==' 'strict-dynamic''. Either the 'unsafe-inline' keyword, a hash ('sha256-OBTN3RiyCV4Bq7dFqZ5a2pAXjnCcCYeTJMO2I/LYKeo='), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.Executing inline script violates the following Content Security Policy directive 'script-src 'self' 'nonce-ktjBSpSNvfbPZ2egQl4Afg==' 'strict-dynamic''. Either the 'unsafe-inline' keyword, a hash ('sha256-kLZZN+LmwaL2wijRNZ9A2oWUPqkHfrsIkyNkem+BMyw='), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.Executing inline script violates the following Content Security Policy directive 'script-src 'self' 'nonce-ktjBSpSNvfbPZ2egQl4Afg==' 'strict-dynamic''. Either the 'unsafe-inline' keyword, a hash ('sha256-Zym5uU7tfiPzDKRipjhoJe6pYcr/P8F+WKnSE5Pv+ps='), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.Executing inline script violates the following Content Security Policy directive 'script-src 'self' 'nonce-ktjBSpSNvfbPZ2egQl4Afg==' 'strict-dynamic''. Either the 'unsafe-inline' keyword, a hash ('sha256-UfcrzoXW4RTekUKBNY13cknkW/8FZIwuROVtp7nyVXM='), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.Executing inline script violates the following Content Security Policy directive 'script-src 'self' 'nonce-ktjBSpSNvfbPZ2egQl4Afg==' 'strict-dynamic''. Either the 'unsafe-inline' keyword, a hash ('sha256-YzPtczy0fWx3mwrb8aSVL0VIN+SxZHDBgbRDeFO4a/g='), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.Executing inline script violates the following Content Security Policy directive 'script-src 'self' 'nonce-ktjBSpSNvfbPZ2egQl4Afg==' 'strict-dynamic''. Either the 'unsafe-inline' keyword, a hash ('sha256-2Ie8QYkVBf66vScl0792if0vNmdMW8UkIsuMvb84VqQ='), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.Executing inline script violates the following Content Security Policy directive 'script-src 'self' 'nonce-ktjBSpSNvfbPZ2egQl4Afg==' 'strict-dynamic''. Either the 'unsafe-inline' keyword, a hash ('sha256-XVxoCdr0Hoqndw6mKNZzv9HoYVDYuomLRTIprSptt0o='), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.
Requests the page made during this step (1), without the files it loaded
GET/teas/mint200a whole page
The layout reads nothing from the request, so the build prerenders the page: ○. Its HTML was made before any nonce existed, so Chrome blocks every script on it, Next.js's too. React never starts.
React starts in two versions: the one made per request, and the one with 'unsafe-inline'. Here's why:
- ○ prerendered. The layout reads nothing from the request, so with Cache Components the whole page is made during the build. Chrome logged a refusal for each of Next.js's scripts. Each refusal of a script file adds Note that 'strict-dynamic' is present, so host-based allowlisting is disabled: coming from the shop's own address isn't enough.
- ◐ static shell. Reading the nonce inside
Suspensekeeps the rest of the page in the static shell. But the shell, with Next.js's scripts in it, was made during the build too. Only the part streamed in later knows the nonce. Next.js's docs say it plainly: Partial Prerendering doesn't work with a nonce-based policy. - ƒ made per request. The root layout reads the nonce outside
Suspense, so nothing can be made ahead of time. Line 5,export const instant = false, allows that. Without it, the build stops, because Cache Components checks that every page has a static shell. - ○ with 'unsafe-inline'. A fixed policy in
next.config.tsdoesn't change between requests, so the page stays prerendered.'unsafe-inline'lets every inline script run, and React starts. So does the hidden review script: Chrome can't tell the shop's inline scripts from anyone else's. - ○ with SRI. The docs offer an experimental way to keep pages static: Subresource Integrity, or SRI. The build adds an
integrityhash to the script tags, five of the seven in this build, and the browser checks each file against its hash. The Network tab shows them. But the page also has inline scripts that Next.js writes, and withscript-src 'self'Chrome blocked every one of them. React didn't start.
K7 stands for one request's nonce. On the left, the HTML was written during next build, before that request existed, so its scripts carry no nonce: ○ and the shell of ◐. On the right, the page is written for the request, so its scripts carry the same K7 as the policy: ƒ.instant is a route segment config option: an export from a layout or page that tells Next.js how to treat that part of the app. Its main job is checking that navigations show something at once. On the root layout, false also turns off the static shell check for every route, so the layout may wait for the request. The price: the Network tab shows Cache-Control: private, no-cache, no-store…. No shared cache may keep these pages, and the server renders every one for every visit. The docs list the same costs: slower first loads, more work for the server, and no CDN caching by default.
Each row is one way to have a policy: whether pages stay static, and which inline scripts can run.
| Approach | Do pages stay static? | Which inline scripts run? |
|---|---|---|
| A nonce made in proxy.ts | No. The root layout reads the nonce, so every page is made for each request (ƒ). | Only scripts with this response's nonce, and the scripts they add. |
A fixed policy in next.config.ts with 'unsafe-inline' | Yes. The policy never changes, so pages stay ○ and ◐. | Every inline script, including one hidden in a review. The policy still limits where script files come from. |
| Experimental SRI with a fixed policy | Yes. The build adds integrity hashes to the script tags. | None, with script-src 'self': in the recording, Next.js's own inline scripts were blocked and React never started. |
The docs suggest nonces when strict rules ask for them: an app with sensitive data, or a rule that forbids 'unsafe-inline'. For a shop whose pages are mostly static, a fixed policy in next.config.ts still limits where scripts, frames and plugins may come from, and keeps the pages fast.
next/script, 'strict-dynamic' and next dev
Scripts and third parties loaded scripts with <Script> from next/script. Next.js's docs pass it the nonce: read it with headers() and give it as the nonce prop. Here the layout loads three scripts: analytics.js from public, an inline welcome script with the nonce, and an inline survey script without one. Pick each policy, and read the Steps tab:
app/layout.tsximport Script from "next/script";import { headers } from "next/headers";import { logScript } from "./log-script"; export const metadata = { title: "Tea shop" };export const instant = false; export default async function RootLayout({ children }: LayoutProps<"/">) { const nonce = (await headers()).get("x-nonce") ?? undefined; return ( <html lang="en"> <body> {children} <Script src="/analytics.js" /> <Script id="welcome" nonce={nonce}> {logScript("the welcome script")} </Script> <Script id="survey">{logScript("the survey script")}</Script> </body> </html> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Fresh and sweet. I drink it every morning.
Lovely!
Scripts that ran: the welcome script, the survey script, analytics.js
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Errors in Chrome's console during this step (1)
Executing inline script violates the following Content Security Policy directive 'script-src 'self' 'nonce-Ecy+G/VGCmA6fnbJTC2fmw==' 'strict-dynamic''. Either the 'unsafe-inline' keyword, a hash ('sha256-I6X5dizqk7FawZCqhGkR6WzwoE7egVd5g/bqSCvEQ8c='), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.
Requests the page made during this step (8), files and other sites included
GET/teas/mint200a whole pageGET/_next/static/chunks/2n2x6ko-morcw.js200application/javascriptGET/_next/static/chunks/1hv73wm-mg223.js200application/javascriptGET/analytics.js200application/javascriptGET/_next/static/chunks/3gjfgdn2oppok.js200application/javascriptGET/_next/static/chunks/turbopack-3-ry682tkyiqk.js200application/javascriptGET/_next/static/chunks/2zfto7c_a6cyg.js200application/javascriptGET/_next/static/chunks/2j8hy4o9oe4-c.js200application/javascript
The welcome script, the survey script and analytics.js all run. next/script adds them from Next.js's code, which has the nonce, and 'strict-dynamic' trusts what trusted scripts add. Only the review's script is blocked.
With 'strict-dynamic', all three ran. The keyword means: a script the policy trusts may add more scripts, and those are trusted too. next/script adds its scripts from Next.js's own code, after the page has started, and that code has the nonce. So the survey script ran without one. The review's script didn't: it's in the page's HTML, and no trusted script added it.
Without 'strict-dynamic', only the nonce and 'self' count. analytics.js comes from the shop's own address, so 'self' allows it. The welcome script has the nonce. The survey script has neither, and Chrome blocked it. Passing the nonce works with either policy, so pass it to every inline <Script>.
Development needs one more word in the policy. This is the same proxy.ts, written for both next dev and production:
import { NextResponse, type NextRequest } from "next/server"; export function proxy(request: NextRequest) { const nonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64"); const dev = process.env.NODE_ENV === "development"; const policy = [ "default-src 'self'", `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${dev ? " 'unsafe-eval'" : ""}`, `style-src 'self' 'nonce-${nonce}'`, "object-src 'none'", "base-uri 'self'", "frame-ancestors 'none'", ].join("; "); const headers = new Headers(request.headers); headers.set("x-nonce", nonce); headers.set("Content-Security-Policy", policy); const response = NextResponse.next({ request: { headers } }); response.headers.set("Content-Security-Policy", policy); return response;} export const config = { matcher: [ { source: "/((?!api|_next/static|_next/image|favicon.ico).*)", missing: [ { type: "header", key: "next-router-prefetch" }, { type: "header", key: "purpose", value: "prefetch" }, ], }, ],}; Line 5 checks whether the app runs with next dev. Line 8 adds 'unsafe-eval' only then. The docs explain why: in development, React uses eval, which runs text as code, to show better error details, like where on the server an error started. Neither React nor Next.js uses it in production, so the production policy leaves it out.
Taint: data that must never reach the browser
Headers protect the page in the browser. Some protection has to happen on the server, before anything is sent. A Server Component can pass any value to a Client Component as a prop, and every prop is sent to the browser in the page's RSC payload, the description of what the Server Components rendered. Pass a whole customer record, and its card token goes with it. Server-only code and environment variables kept secret keys on the server; this is the same danger for data.
React has two functions that mark data as never allowed to cross to the browser. Marking it is called tainting. Both are experimental, and Next.js turns them on with experimental.taint. Pick each version of the account page and read the Terminal tab:
app/account/page.tsximport { getCustomer } from "../../lib/customers";import Greeting from "./greeting"; export default async function AccountPage() { const customer = await getCustomer(); return ( <main> <h1>Your account</h1> <Greeting customer={customer} /> </main> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled- Experiments (use with caution): ✓ taint Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...Error occurred prerendering page "/account". Read more: https://nextjs.org/docs/messages/prerender-errorError: Don't pass the whole customer to the browser. Pick the fields you need. at ignore-listed frames { digest: '2075840929'}Export encountered an error on /account/page: /account, exiting the build.⨯ Next.js build worker exited with code: 1 and signal: nullLine 9 passes the tainted object itself. React stops the prerender, and the build fails with the message from line 8 of lib/customers.ts.
In lib/customers.ts, lines 7 to 10 call experimental_taintObjectReference with a message and the customer object: this object may never be passed to a Client Component. Line 11 calls experimental_taintUniqueValue with a message, the customer, and the token: this exact string may never be passed, for as long as the customer object exists. React checks every value the moment it would cross to the browser.
The whole customer stops the build with line 8's message. The token, in a new object, stops it with line 11's. Only the name builds, and the page says Hello, Asha. React throws the error whenever a tainted value would cross; here that happened while the build prerendered /account. Without the flag, the build stops at the import: You're importing experimental_taintObjectReference from React which requires experimental.taint: true in your Next.js config. The docs add that the flag switches the app folder to React's experimental channel, a build of React with features that aren't stable yet, and taints process.env itself, so the whole object can't be passed.
lib/customers.ts taint the customer object and its card token. React checks each value as it would cross to a Client Component: the object and the token stop the build, and the name crosses.Taint has limits, which the docs list:
- It follows one object. A copy, like
{ ...customer }, is a new object without the object's taint. - It follows one exact value. Something made from it, like
"Card: " + token, isn't tainted. - So it's a safety net, not the plan. The plan is to return only the fields each page needs, from the functions that read the data. Authorization and the data access layer builds that.
The code you install
The packages your app uses run somewhere: on your server, in Server Components and proxy.ts, during next build, or in your visitors' browsers, when a Client Component imports it. A package with a flaw, or with harmful code, runs with the same rights as your own code. Most packages are fine. A few habits keep you in control of the rest.
The lockfile. package-lock.json records the exact version of every package your app uses, the packages they use too, where each was downloaded from, and an integrity hash of its contents. Commit it. This is its entry for Next.js in tools/nextjs-examples, the project every recording on this page was built in:
"node_modules/next": { "version": "16.4.3", "resolved": "https://registry.npmjs.org/next/-/next-16.4.3.tgz", "integrity": "sha512-OqxKTGi9u80FydydsMNYNjnuje9Au+kvHlt2GZ//ftdpDHm1dxKqAaENoOOTX5wKzn+9vaKUNIfZeyZUuLhCnA==", ...}Line 2 is the exact version. Line 4 is a hash of the package's file: npm refuses a download whose contents don't match it. npm ci installs exactly what the lockfile lists, and stops if package.json asks for something else. The course's recorder installs its project with it, so every example uses the same Next.js 16.4.3.
Exact versions. In package.json, a range like ^5 lets a fresh install pick any 5.x. create-next-app writes exact versions for the three packages that run your app:
{ "name": "my-app", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start" }, "dependencies": { "next": "16.4.3", "react": "19.3.0", "react-dom": "19.3.0" }, "devDependencies": { "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "typescript": "^5" }}Lines 11 to 13 pin next, react and react-dom. Lines 16 to 19 use ranges for the types and TypeScript, which only run while you develop and build.
Checking what you have. Two commands ask the npm registry about the versions in your lockfile:
npm ci
npm audit
npm audit signaturesnpm audit compares your versions with published security advisories, warnings that say which versions of a package have a flaw and which fix it. npm audit signatures checks that each package was signed by the registry, and, for packages that have one, its provenance attestation: a signed record of the source code and the build that made it. This is what they printed for tools/nextjs-examples on October 30, 2026:
$ npm audit
found 0 vulnerabilities
$ npm audit signatures
audited 24 packages in 3s
24 packages have verified registry signatures
16 packages have verified attestationsThe answers come from the registry, so they change as new advisories appear: run them again, often, and in your deploy steps. Add packages with care, too: fewer packages mean fewer to watch, and a misspelled name can install somebody else's package. Supply-chain security, in the JavaScript course, goes further.
The tea shop's whole setup
Here are the pieces together: the security headers from next.config.ts, the policy and its nonce from proxy.ts, with the development setting, and the root layout that makes every page per request.
proxy.tsimport { NextResponse, type NextRequest } from "next/server"; export function proxy(request: NextRequest) { const nonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64"); const dev = process.env.NODE_ENV === "development"; const policy = [ "default-src 'self'", `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${dev ? " 'unsafe-eval'" : ""}`, `style-src 'self' 'nonce-${nonce}'`, "object-src 'none'", "base-uri 'self'", "frame-ancestors 'none'", ].join("; "); const headers = new Headers(request.headers); headers.set("x-nonce", nonce); headers.set("Content-Security-Policy", policy); const response = NextResponse.next({ request: { headers } }); response.headers.set("Content-Security-Policy", policy); return response;} export const config = { matcher: [ { source: "/((?!api|_next/static|_next/image|favicon.ico).*)", missing: [ { type: "header", key: "next-router-prefetch" }, { type: "header", key: "purpose", value: "prefetch" }, ], }, ],};GET /teas/mint200
- Response headers
Strict-Transport-Securitymax-age=86400X-Content-Type-OptionsnosniffX-Frame-OptionsDENYReferrer-Policystrict-origin-when-cross-originPermissions-Policycamera=(), microphone=(), geolocation=()content-security-policydefault-src 'self'; script-src 'self' 'nonce-Jf03K3J9f7ZipFfrbms85A==' 'strict-dynamic'; style-src 'self' 'nonce-Jf03K3J9f7ZipFfrbms85A=='; object-src 'none'; base-uri 'self'; frame-ancestors 'none'Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-stale-time300x-nextjs-prerender1x-nextjs-postponed1Cache-Controlprivate, no-cache, no-store, max-age=0, must-revalidateContent-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 13 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" nonce="Jf03K3J9f7ZipFfrbms85A==" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async="" nonce="Jf03K3J9f7ZipFfrbms85A=="></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async="" nonce="Jf03K3J9f7ZipFfrbms85A=="></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async="" nonce="Jf03K3J9f7ZipFfrbms85A=="></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async="" nonce="Jf03K3J9f7ZipFfrbms85A=="></script>
<script src="/_next/static/chunks/2j8hy4o9oe4-c.js" async="" nonce="Jf03K3J9f7ZipFfrbms85A=="></script>
<title>Tea shop</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule="" nonce="Jf03K3J9f7ZipFfrbms85A=="></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<script nonce="Jf03K3J9f7ZipFfrbms85A==">…</script>
<!--&-->
<!--&-->
<!--&-->
<main>
<h1>Mint tea</h1>
<h2>Reviews</h2>
<div>
<p>Fresh and sweet. I drink it every morning.</p>
</div>
<div>
<p>Lovely!</p>
<script>…</script>
</div>
<p>React has not started, so nothing is listed.</p>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<!--/&-->
<script nonce="Jf03K3J9f7ZipFfrbms85A==" id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" nonce="Jf03K3J9f7ZipFfrbms85A==" async=""></script>
<script nonce="Jf03K3J9f7ZipFfrbms85A==">…</script>
<script nonce="Jf03K3J9f7ZipFfrbms85A==">…</script>
<script nonce="Jf03K3J9f7ZipFfrbms85A==">…</script>
</body>
</html>One response, both sources: the Network tab shows the five headers from next.config.ts and the content-security-policy from line 20 of proxy.ts. The policy has no 'unsafe-eval', because next start isn't development. The Steps tab shows the same result as before: React started, and only the shop's script ran.
Each protection has its place. Sort these jobs:
- Tell browsers to use only HTTPS for this site.
- Stop browsers guessing what kind of file a response is.
- Make a new nonce for every page.
- Send a Content Security Policy that names this response's nonce.
- Stop the whole customer record from reaching a Client Component.
- Keep a card token out of the browser, whatever object it's in.
- Install exactly the versions you tested.
- Find out that a package you use has a known flaw.
Sort each job: next.config.ts, proxy.ts, the code that reads the data, or your dependencies.
Common mix-ups
- "Headers in next.config.ts make pages dynamic." No. The recorded
/was still○andx-nextjs-cache: HIT, with every header. - "A Content Security Policy makes untrusted HTML safe." No. It's the second wall. Don't put HTML you didn't write into a page, or sanitize it first.
- "Adding the policy in proxy.ts is all it takes." No. On a
○or◐page, Chrome blocked Next.js's own scripts. The root layout must read the nonce, so every page is made per request. - "
'strict-dynamic'lets any script run." No. Only scripts that a trusted script adds. The review's script was blocked with it. - "HSTS works on
http://localhost." No. Browsers ignoreStrict-Transport-Securityon a page sent over plain HTTP. - "Taint keeps every secret out of the browser." No. It follows one object and one value. Copies and values made from them aren't tracked.
- "A lockfile means my packages are safe." No. It keeps them the same, flaws included.
npm audittells you when one is known.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
With this next.config.ts, Chrome refused to show the shop's home page in the partners page's frame. Which header made it refuse?
import type { NextConfig } from "next";
const securityHeaders = [
{ key: "Strict-Transport-Security", value: "max-age=86400" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
];
const nextConfig: NextConfig = {
/* config options here */
cacheComponents: true,
partialPrefetching: true,
poweredByHeader: false,
async headers() {
return [{ source: "/(.*)", headers: securityHeaders }];
},
};
export default nextConfig;
X-Frame-Options: DENY, line 6. Chrome logged: Refused to display '…/' in a frame because it set 'X-Frame-Options' to 'deny'. The policy's frame-ancestors 'none' says the same in the newer way.
Every response from this app says X-Powered-By: Next.js. Which setting removes the header?
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
cacheComponents: true,
partialPrefetching: true,
};
export default nextConfig;
import type { NextConfig } from "next";
const securityHeaders = [
{ key: "Strict-Transport-Security", value: "max-age=86400" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
];
const nextConfig: NextConfig = {
/* config options here */
cacheComponents: true,
partialPrefetching: true,
poweredByHeader: false,
async headers() {
return [{ source: "/(.*)", headers: securityHeaders }];
},
};
export default nextConfig;
poweredByHeader: false, on line 15 of the shop's config. The recorded responses with it have no X-Powered-By; the ones with create-next-app's config say X-Powered-By: Next.js.
This root layout reads the nonce for every page, and next build stops with Next.js encountered uncached or runtime data during prerendering. Which line is missing?
import { headers } from "next/headers";
import { ShopScript } from "./shop-script";
export const metadata = { title: "Tea shop" };
export default async function RootLayout({ children }: LayoutProps<"/">) {
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<html lang="en">
<body>
<ShopScript nonce={nonce} />
{children}
</body>
</html>
);
}
import { headers } from "next/headers";
import { ShopScript } from "./shop-script";
export const metadata = { title: "Tea shop" };
export const instant = false;
export default async function RootLayout({ children }: LayoutProps<"/">) {
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<html lang="en">
<body>
<ShopScript nonce={nonce} />
{children}
</body>
</html>
);
}
export const instant = false. The recorded build without it stopped with: Next.js encountered uncached or runtime data during prerendering, and listed [block] Set export const instant = false to allow a blocking route. With the line, every page is ƒ and gets its nonce.
The shop's policy is the one without 'strict-dynamic'. Which of these three scripts doesn't run?
import Script from "next/script";
import { headers } from "next/headers";
import { logScript } from "./log-script";
export const metadata = { title: "Tea shop" };
export const instant = false;
export default async function RootLayout({ children }: LayoutProps<"/">) {
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<html lang="en">
<body>
{children}
<Script src="/analytics.js" />
<Script id="welcome" nonce={nonce}>
{logScript("the welcome script")}
</Script>
<Script id="survey">{logScript("the survey script")}</Script>
</body>
</html>
);
}
The survey script, on line 18: it's inline, so 'self' doesn't cover it, and it has no nonce. Add nonce={nonce} as line 15 does for the welcome script. The recording without 'strict-dynamic' lists the welcome script, analytics.js.
A teammate passes { ...customer } instead of the customer, "to get around the taint". What does next build do?
import { getCustomer } from "../../lib/customers";
import Greeting from "./greeting";
export default async function AccountPage() {
const customer = await getCustomer();
return (
<main>
<h1>Your account</h1>
<Greeting customer={{ ...customer }} />
</main>
);
}
import { getCustomer } from "../../lib/customers";
import Greeting from "./greeting";
export default async function AccountPage() {
const customer = await getCustomer();
return (
<main>
<h1>Your account</h1>
<Greeting customer={{ name: customer.name }} />
</main>
);
}
The build fails with: Error: Don't pass a card token to the browser. The copy lost the object's taint, but cardToken is still the tainted string. Pass only { name: customer.name }, which builds.
The course's recorder installs Next.js 16.4.3 in tools/nextjs-examples before it records anything, and must get exactly the versions in the lockfile. Which npm command does it run?
npm ci. It installs exactly the versions in package-lock.json and stops if package.json asks for something else. The course's recorder runs it in tools/nextjs-examples, so every recording uses Next.js 16.4.3.
Check your understanding
For each question, ask who sends the rule, who enforces it, and when the page's HTML was made.
Question 1 of 7This next.config.ts lists its headers for
source: "/(.*)". Which responses carry them?Read the code, then predictTypeScriptimport type { NextConfig } from "next"; const securityHeaders = [ { key: "Strict-Transport-Security", value: "max-age=86400" }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "X-Frame-Options", value: "DENY" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, ]; const nextConfig: NextConfig = { /* config options here */ cacheComponents: true, partialPrefetching: true, poweredByHeader: false, async headers() { return [{ source: "/(.*)", headers: securityHeaders }]; }, }; export default nextConfig;Choose an answer to see the explanation.
Question 2 of 7proxy.ts sends a policy with a nonce, but the mint tea page is still
○. What happens in Chrome?Choose an answer to see the explanation.
Question 3 of 7Why does a static shell (
◐) break with a nonce too?Choose an answer to see the explanation.
Question 4 of 7With
'strict-dynamic'in the policy, why did<Script id="survey">, which has no nonce, still run?Choose an answer to see the explanation.
Question 5 of 7Why does the docs' proxy.ts add
'unsafe-eval'only whenNODE_ENVisdevelopment?Choose an answer to see the explanation.
Question 6 of 7This page passes a copy of the customer. Both taint calls ran in
getCustomer(). What doesnext buildprint?Read the code, then predictTSXimport { getCustomer } from "../../lib/customers"; import Greeting from "./greeting"; export default async function AccountPage() { const customer = await getCustomer(); return ( <main> <h1>Your account</h1> <Greeting customer={{ ...customer }} /> </main> ); }Choose an answer to see the explanation.
Question 7 of 7What does
npm cido thatnpm installdoesn't?Choose an answer to see the explanation.
Key takeaways
- Security headers are rules the server sends and the browser enforces. List fixed ones in
next.config.ts'sheaders();/(.*)covers every path, and pages stay prerendered. - A Content Security Policy decides which scripts run. With a nonce, only scripts carrying this response's value run, and an injected script is blocked.
- Make the nonce in proxy.ts, set the policy on the request and the response, and read it with
headers(). Next.js adds it to its own scripts. - A nonce needs every page made per request:
○and◐pages carry build-time scripts. With Cache Components, the root layout reads the nonce, withexport const instant = false. 'strict-dynamic'trusts scripts that trusted scripts add; pass the nonce to inline<Script>anyway. Add'unsafe-eval'only in development.- Taint marks objects and values that must never reach a Client Component; return only the fields you need anyway.
- Commit the lockfile, install with
npm ci, and runnpm auditoften.
Remember the one-liner.
Fixed rules in next.config.ts, a new nonce for every request in proxy.ts, and nothing secret across the boundary.
These links point at release v16.4.3. Next.js finds the policy header on the request at lines 80 to 85 of parse-request-headers.ts, and takes the nonce out of script-src, or default-src, in get-script-nonce-from-header.tsx, which skips a badly formed one. The taint flag is one of four that switch the app to React's experimental channel, at lines 4 to 10 of needs-experimental-react.ts.
Coming next: Case study: the middleware bypass. In March 2025, one request header let attackers skip middleware.ts entirely. It shows how to read a security advisory, and why every check needs a second one next to the data.