cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

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.

By the end, you can
  • 01
    Send security headersAdd headers like Strict-Transport-Security and X-Frame-Options to every response from next.config.ts, and see the browser obey them.
  • 02
    Add 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.
  • 03
    Keep 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.

The tea shop sends five security headers with every responseNext.js
app/partners/public/
next.config.ts
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;
Recorded from Next.js 16.4.3

GET /200

Response headers
Strict-Transport-Security max-age=86400
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy camera=(), microphone=(), geolocation=()
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
Cache-Control s-maxage=31536000
Content-Type text/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.

On the left, a window for the response lists five headers: Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options DENY, Referrer-Policy and Permissions-Policy. An arrow goes to a browser window on the right, which lists the rule it enforces for each: HTTPS only for a day, no guessing a file's type, never inside a frame, send less of the address, and no camera, microphone or location.the response carriesresponseStrict-Transport-SecurityX-Content-Type-OptionsX-Frame-Options: DENYReferrer-PolicyPermissions-Policythe browser enforcesbrowserHTTPS only, for a dayno guessing a file's typenever inside a framesend less of the addressno camera, mic or locationone line on the server, a rule in every visitor's browser
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.
Real-life analogyThe rules printed on a concert ticket

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
The main idea

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.

The shop's security headers
HeaderWhat it tells the browserWhat it stops
Strict-Transport-SecurityUse 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: nosniffTrust 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: DENYNever 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-originTell 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: 86400 is 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 show 63072000, two years).
  • Two more words you'll see in HSTS values. includeSubDomains covers every subdomain, the ones you have now and any you add later, like blog. in front of the shop's address; add it only when all of them will serve HTTPS. preload only 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-Options is the older way to say "no frames". Next.js's docs say a Content Security Policy's frame-ancestors has replaced it, and the shop's policy below has it too. Sending both does no harm.
  • Referrer-Policy: strict-origin-when-cross-origin is 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:

Playground: what do the headers change?
The tea shop, with this next.config.tsNext.js
app/partners/public/
next.config.ts
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  /* config options here */  cacheComponents: true,  partialPrefetching: true,}; export default nextConfig;
Recorded from Next.js 16.4.3

GET /200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/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>
Try it yourself
create-next-app's config

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.

Both versions were built with next build and run with next start from Next.js 16.4.3, then real Chrome opened /partners, which shows the home page in a frame. Only next.config.ts changes. Reset goes back to the first version.

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.

Fixed headers here, changing ones in proxy.ts

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

A review with a script in it, and no policyNext.js
app/teas/mint/lib/
app/teas/mint/page.tsx
import { 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>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teas/mintTea shop
Mint tea
Reviews

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

  1. 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:

The shop's policy
DirectiveWhat 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.ts makes a nonce and a policy for every requestNext.js
app/teas/mint/lib/
proxy.ts
import { 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" },      ],    },  ],};
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teas/mintTea shop
Mint tea
Reviews

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)

  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

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

A card for proxy.ts makes a nonce, N1, and puts it in the policy. An arrow goes to a window for the HTML Next.js renders, with three scripts: Next.js's scripts with nonce N1, the shop script with nonce N1, and the review's script with no nonce. Next to the first two, the label runs; next to the review's script, the label blocked.proxy.tsnonce N1policy: nonce-N1to Next.js and the browserHTML from Next.jsNext.js's scripts: nonce=N1the shop script: nonce=N1the review's script: nonerunsrunsblockedthe browser compares each nonce with the policy's
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:

Playground: who decides which scripts run?
Who does whatNo policy
What the visitor seesafter step 1
tea-shop.example/teas/mint

A blank page

React isn't running yet: clicks do nothing.

The page isn't complete yet.

Step 1 of 4
Browser: The visitor opens /teas/mint

The browser asks the shop for the mint tea page.

Each step is one thing that happens, in order. The screens are the recorded pages: the HTML as it arrived, then the page in real Chrome once React had started. Reset goes back to the first journey's first step.
Let proxy.ts set the policy header itself

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?

Playground: which pages survive a strict policy?
The mint tea page, with this policy and this layoutNext.js
app/teas/mint/lib/
app/layout.tsx
import { 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>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teas/mintTea shop
Mint tea
Reviews

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)

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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.
  11. 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.
  12. 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.
  13. 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.
  14. 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.
  15. 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.
  16. 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.
  17. 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

  1. GET/teas/mint200a whole page
Try it yourself
○ prerendered

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.

Every version was built with next build and run with next start from Next.js 16.4.3, then opened in real Chrome; the Steps tab shows what Chrome's console logged. The Terminal tab shows the symbol the build printed. Reset goes back to the first version.

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 Suspense keeps 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.ts doesn'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 integrity hash 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 with script-src 'self' Chrome blocked every one of them. React didn't start.
Two halves. Left, a prerendered page: a window for next build writes a script tag with no nonce, long before the request. The request later brings a policy with nonce K7. The script has no K7, so it's blocked. Right, a page made for the request: a window for the request writes a script tag with nonce K7, and the policy says nonce K7, so it runs.○ made during the buildnext build<script> no noncerequest's policy: nonce-K7blocked: no K7 on the scriptƒ made for this requestthis request<script nonce="K7">request's policy: nonce-K7runs: the nonces match
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.

Three ways to have a policy
ApproachDo pages stay static?Which inline scripts run?
A nonce made in proxy.tsNo. 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 policyYes. 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:

Playground: which scripts does the policy let run?
The mint tea page, loading three scripts with next/scriptNext.js
app/teas/mint/lib/public/
app/layout.tsx
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>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teas/mintTea shop
Mint tea
Reviews

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)

  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

  1. GET/teas/mint200a whole page
  2. GET/_next/static/chunks/2n2x6ko-morcw.js200application/javascript
  3. GET/_next/static/chunks/1hv73wm-mg223.js200application/javascript
  4. GET/analytics.js200application/javascript
  5. GET/_next/static/chunks/3gjfgdn2oppok.js200application/javascript
  6. GET/_next/static/chunks/turbopack-3-ry682tkyiqk.js200application/javascript
  7. GET/_next/static/chunks/2zfto7c_a6cyg.js200application/javascript
  8. GET/_next/static/chunks/2j8hy4o9oe4-c.js200application/javascript
Try it yourself
With 'strict-dynamic'

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.

Both versions were built and run with Next.js 16.4.3 and opened in real Chrome, which listed every file the page loaded. Only line 7 of proxy.ts changes. Reset goes back to the first version.

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:

proxy.ts, with what next dev needsTypeScript
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:

Playground: what may cross to the browser?
The account page, with the customer tainted in lib/customers.tsNext.js
app/account/lib/
app/account/page.tsx
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>  );}
next build failed · Next.js 16.4.3
$ 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: null
Try it yourself
The whole customer

Line 9 passes the tainted object itself. React stops the prerender, and the build fails with the message from line 8 of lib/customers.ts.

Every version was built with next build from Next.js 16.4.3; the Terminal shows what it printed. The first three change only line 9 of app/account/page.tsx; the last uses create-next-app's next.config.ts. Reset goes back to the first version.

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.

Left, a window for the server holds three values: the tainted customer object, the tainted customer.cardToken, and customer.name. A dashed line marks the boundary to the browser on the right. The customer object and the card token are crossed out on the browser's side, marked build stops. customer.name arrives as Asha, marked Hello, Asha.serverlib/customers.tscustomercustomer.cardTokencustomer.namethe boundarybrowserwhole customerbuild stopstok_mint_4242build stopsAshaHello, Asha
Lines 7 to 11 of 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:

package-lock.json: the entry for next (shortened)JSON
"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:

package.json, as create-next-app writes itJSON
{  "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:

Install exactly, then checkShell
npm ci
npm audit
npm audit signatures

npm 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:

What npm printed for the recorder's projectText
$ npm audit
found 0 vulnerabilities

$ npm audit signatures
audited 24 packages in 3s

24 packages have verified registry signatures

16 packages have verified attestations

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

The tea shop with its headers and its policyNext.js
app/teas/mint/lib/
proxy.ts
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" },      ],    },  ],};
Recorded from Next.js 16.4.3

GET /teas/mint200

Response headers
Strict-Transport-Security max-age=86400
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy camera=(), microphone=(), geolocation=()
content-security-policy default-src 'self'; script-src 'self' 'nonce-Jf03K3J9f7ZipFfrbms85A==' 'strict-dynamic'; style-src 'self' 'nonce-Jf03K3J9f7ZipFfrbms85A=='; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-stale-time 300
x-nextjs-prerender 1
x-nextjs-postponed 1
Cache-Control private, no-cache, no-store, max-age=0, must-revalidate
Content-Type text/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:

Where does this protection go?
  • 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.
Try it yourself
0 of 8 correct

Sort each job: next.config.ts, proxy.ts, the code that reads the data, or your dependencies.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "Headers in next.config.ts make pages dynamic." No. The recorded / was still ○ and x-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 ignore Strict-Transport-Security on 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 audit tells 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.

Exercise 1 · Warm-upThe frame that stayed empty

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?

Starter codeTypeScript
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;

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upStop saying which framework you run

    Every response from this app says X-Powered-By: Next.js. Which setting removes the header?

    Starter codeTypeScript
    import type { NextConfig } from "next";
    
    const nextConfig: NextConfig = {
      /* config options here */
      cacheComponents: true,
      partialPrefetching: true,
    };
    
    export default nextConfig;
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeThe build that stopped

      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?

      Starter codeTSX
      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>
        );
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeWhich script was blocked?

        The shop's policy is the one without 'strict-dynamic'. Which of these three scripts doesn't run?

        Starter codeTSX
        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>
          );
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeA copy of the customer

          A teammate passes { ...customer } instead of the customer, "to get around the taint". What does next build do?

          Starter codeTSX
          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>
            );
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeThe same Next.js every time

            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?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask who sends the rule, who enforces it, and when the page's HTML was made.

              The security headers quiz · 7 questionsScore: first tries count
              1. Question 1 of 7This next.config.ts lists its headers for source: "/(.*)". Which responses carry them?

                Read the code, then predictTypeScript
                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;
                

                Choose an answer to see the explanation.

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

              3. Question 3 of 7Why does a static shell (◐) break with a nonce too?

                Choose an answer to see the explanation.

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

              5. Question 5 of 7Why does the docs' proxy.ts add 'unsafe-eval' only when NODE_ENV is development?

                Choose an answer to see the explanation.

              6. Question 6 of 7This page passes a copy of the customer. Both taint calls ran in getCustomer(). What does next build print?

                Read the code, then predictTSX
                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>
                  );
                }
                

                Choose an answer to see the explanation.

              7. Question 7 of 7What does npm ci do that npm install doesn'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's headers(); /(.*) 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, with export 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 run npm audit often.

              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.

              Read it in Next.js's own code

              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.

              CompleteFrontend Clear concepts. Working examples.