Data Security & CSP

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were checked; the official documentation reports @doc-version 16.3.8), React 19.3.0, Node.js 20.9.0 or later and TypeScript 5.1 or later. Data security is mostly an App Router topic, because React Server Components move data access onto the server and make it easy to pass too much of it to the browser; the Pages Router has the same risks in a different shape (props returned from getServerSideProps are serialised into the HTML) and a section below shows it. Caching matters in two places: a nonce-based Content Security Policy needs per-request rendering, so it behaves differently under the opt-in Cache Components model (cacheComponents: true) than under the previous model (the default); every example says which one it uses. Every example was built and run with Next.js 16.3.8, Node.js 24.16 and npm 11.13 on macOS in a scratch copy of the Bookshelf application that the rest of this section builds, using next build, next start, curl and, for Content Security Policy, a real browser; each one is followed by the official page it derives from. Values that would be secrets in a real application (nonces, hashes, build identifiers, keys) are shown as <redacted> or as placeholders.

Authentication itself (sessions, providers, verifySession) is the subject of the authentication and authorisation page of this section; this page assumes a function that tells you who the caller is and concentrates on what the server may hand back, what the browser may run, and what an attacker may reach without ever using your UI. The wider OAuth background is in OAuth Security Best Practices.

The Threats and the Controls That Answer Them

Four different things go wrong in a Next.js application, and each has its own control. Keeping them apart avoids the usual mistake of expecting one feature (for example a login page) to cover all of them.

Table 1. Threats, controls and where each one is covered
Threat Control Section

A Server Component passes a whole database row to a Client Component, so private columns end up in the HTML and the RSC payload

A Data Access Layer that returns Data Transfer Objects, narrow props types, server-only, and (as an extra net) tainting

DAL and DTOs, server-only, taint

A secret is bundled into JavaScript that every visitor downloads

Read secrets only on the server; only genuinely public values get the NEXT_PUBLIC_ prefix

Environment variables

Someone calls a Server Action, Route Handler or Proxy-protected route directly, without your UI

Authenticate, authorise and validate inside every entry point; framework protections only add depth

Server Actions

Script injection (XSS) through a URL, an HTML string or serialised JSON

React escaping, safe URL handling, escaped JSON in inline scripts, and a Content Security Policy

XSS traps, CSP

Weak browser defaults (framing, sniffing, plain HTTP, referrer leaks)

Response headers set in next.config or Proxy

Security headers

A known vulnerability in Next.js itself

Staying on the latest patch of a supported line

Patch releases

The next diagram shows where data crosses a trust boundary in an App Router page, which is the idea behind all of the controls that follow.

flowchart LR subgraph Server["Server: trusted"] DB[("Database and secrets
process.env")] DAL["Data Access Layer
server-only"] SC["Server Components"] SA["Server Actions and
Route Handlers"] end subgraph Browser["Browser: untrusted"] CC["Client Components"] JS["Bundled JavaScript"] end DB --> DAL DAL -->|"DTO only"| SC SC -->|"props, in HTML and RSC payload"| CC CC -->|"POST: public endpoint"| SA SA --> DAL DB -. "NEXT_PUBLIC_ values inlined at build" .-> JS

Everything that crosses an arrow into the browser is readable by the user, and everything that comes back through an arrow from the browser is attacker-controlled input.

Three Ways to Fetch Data, and Which One Is Safe by Construction

The official data-security guide recommends three approaches and asks you to pick one and stay with it, so that both developers and auditors know what to expect and an exception looks suspicious.

Table 2. The three data fetching approaches in the official guide
Approach When Security stance

External HTTP APIs

Existing large applications and organisations with backend teams in other languages

Zero trust: Server Components call REST or GraphQL with fetch, forwarding the cookie, exactly as a browser would. Existing API security practice applies. Do not assume that a request from your own server is safe

Data Access Layer (DAL)

New projects (recommended)

One internal, server-only library that performs authorisation checks and returns minimal DTOs; the only code that imports the database client and reads process.env

Component-level data access

Prototypes and learning

Queries inside Server Components. Quick, but it is easy to pass a whole row to a Client Component by accident

The Data Access Layer and DTOs

A Data Access Layer is a plain module (or folder) that is the only way the rest of the application reaches data. It has three jobs: run only on the server, check what the current user may see, and return a Data Transfer Object (DTO), an object shaped for one purpose that contains nothing else. A Server Component then holds only data the user is entitled to, so passing it to a Client Component cannot leak more than the user could already see.

The scratch application has a fake database whose user rows carry columns that must never leave the server:

// lib/db.ts
import 'server-only'

export type UserRecord = {
  id: string
  name: string
  email: string
  phone: string
  role: 'member' | 'admin'
  pwHash: string
  internalNotes: string
}

export const users: UserRecord[] = [
  { id: 'u1', name: 'Ada', email: 'ada@example.com', phone: '+00 000 0001', role: 'member',
    pwHash: 'PLACEHOLDER_HASH_1', internalNotes: 'late fees waived' },
  // ...more rows
]

The DAL reads the session, applies the privacy rule, and returns the DTO:

// lib/dal.ts
import 'server-only'
import { cache } from 'react'
import { cookies } from 'next/headers'
import { users, type UserRecord } from './db'

// Stand-in for the verifySession() of the authentication page: a real one
// verifies a signed or encrypted session cookie, never a bare user id.
export const verifySession = cache(async () => {
  const id = (await cookies()).get('demo-user')?.value
  const user = users.find((u) => u.id === id)
  return user ? { userId: user.id, role: user.role } : null
})

// The DTO is the only shape that leaves the DAL.
export type ProfileDTO = { name: string; email: string | null }

export async function getProfileDTO(id: string): Promise<ProfileDTO | null> {
  const viewer = await verifySession()
  const row = users.find((u) => u.id === id)
  if (!viewer || !row) return null
  const mayReadEmail = viewer.userId === row.id || viewer.role === 'admin'
  return { name: row.name, email: mayReadEmail ? row.email : null }
}

// Raw record: only for server code that needs every column.
export async function getUserRecord(id: string): Promise<UserRecord | undefined> {
  return users.find((u) => u.id === id)
}

cache from React memoises verifySession for the duration of one request, so every call site can ask "who is this?" without passing the session down as props, which is exactly the habit that ends with a session object in a Client Component. The guide uses this pattern too. The page and the Client Component then look like this:

// app/sec/leak-dto/page.tsx
import { connection } from 'next/server'
import { getProfileDTO } from '@/lib/dal'
import { ProfileCard } from '@/components/profile-card'

export default async function Page() {
  await connection() // read the cookie per request (see the caching pages)
  const profile = await getProfileDTO('u1')
  if (!profile) return <p>Sign in</p>
  return <ProfileCard user={profile} />
}
// components/profile-card.tsx
'use client'

import { useState } from 'react'

// Props are typed as the DTO, not as the database row.
export function ProfileCard({ user }: { user: { name: string; email: string | null } }) {
  const [open, setOpen] = useState(false)
  return (
    <section>
      <h1>{user.name}</h1>
      <button onClick={() => setOpen(!open)}>{open ? 'Hide' : 'Show'} contact</button>
      {open && <p>{user.email ?? 'hidden'}</p>}
    </section>
  )
}

To see why the typed props and the DTO matter, the same data was sent the careless way, a Server Component passing the whole row to a Client Component whose props type accepts anything:

// app/sec/leak-bad/page.tsx
export default async function Page() {
  await connection()
  const row = await getUserRecord('u1')
  // BAD: every column of the row is serialised into the RSC payload.
  return <ProfileCardLoose user={row!} />
}

Both pages were built (next build, previous model) and requested with curl as the demo user u1, counting which private values appear in the response body:

$ curl -s -b 'demo-user=u1' http://localhost:3802/sec/leak-bad \
    | grep -o 'PLACEHOLDER_HASH_1\|late fees waived\|ada@example.com\|+00 000 0001' | sort | uniq -c
   1 +00 000 0001
   1 PLACEHOLDER_HASH_1
   1 ada@example.com
   1 late fees waived

$ curl -s -b 'demo-user=u1' http://localhost:3802/sec/leak-dto \
    | grep -o 'PLACEHOLDER_HASH_1\|late fees waived\|+00 000 0001' | sort | uniq -c
$ curl -s -b 'demo-user=u2' http://localhost:3802/sec/leak-dto | grep -c 'ada@example.com'
0

The first page leaks the hash, the internal note and the phone number into the page source even though the component renders only a name. The DTO page contains none of them, and when a different user (u2) asks for Ada’s profile the privacy rule removes the e-mail address too. Two details deserve emphasis:

  • Anything in a Client Component’s props is in the HTML and in the RSC payload, even if the UI hides it behind a button. "Hidden by CSS or by a click" is not "private".

  • The DTO is deliberately a new object. Spreading the row into it ({ …​row, extra }) would copy the secrets with it.

The guide also lists the same idea for the component-level approach: sanitise before passing, and make the Client Component’s props type narrow ("a limited object with just the fields necessary") so a Server Component is not even invited to pass more. A DAL also simplifies auditing, because the places that import the database client and read process.env can be found with a search.

server-only: Keep Server Code Out of the Client Bundle

A DAL is only a convention until the build enforces it. Putting import 'server-only' at the top of a module (the server-only package, which Next.js handles internally) turns an accidental import from a Client Component into a build error, so database access code, API keys read from process.env and business rules cannot be bundled for the browser. The lib/db.ts and lib/dal.ts files above both start with it. The page is the same one the Server and Client Components page describes as "environment poisoning"; here it is the data-security control.

A temporary Client Component that imports the database module:

// app/sec/client-import/widget.tsx (temporary, to provoke the error)
'use client'

import { users } from '@/lib/db'

export function Widget() {
  return <p>{users.length}</p>
}

next build (Turbopack) fails with an error that names the offending import and prints the import chain from the Client Component to the module:

./lib/db.ts:1:1
Error: You're importing a module that depends on "server-only". This API is only available in Server Components in the App Router, but you are using it in the Pages Router.
> 1 | import 'server-only'
    | ^^^^^^^^^^^^^^^^^^^^

Import traces:
  Client Component Browser:
    ./lib/db.ts [Client Component Browser]
    ./app/sec/client-import/widget.tsx [Client Component Browser]
    ./app/sec/client-import/widget.tsx [Server Component]
    ./app/sec/client-import/page.tsx [Server Component]

The message says "Pages Router" even though the app uses only the App Router (the wording is generic for any client environment in this release); read the import trace, not the sentence. Importing a module that uses next/headers fails the same way. The mirror package, client-only, marks code that must never run on the server. The documentation notes that the npm contents of these packages are not used, since Next.js handles the imports itself, but that you may install them to satisfy a linter that flags extraneous dependencies:

npm install server-only

One limit to remember: server-only protects modules. It does not stop a Server Component from passing a value it obtained from such a module to a Client Component. That is what DTOs, narrow props types and (as a safety net) tainting are for.

Tainting: A Safety Net, Not a Control

React has two experimental APIs that mark data as "must not cross to the client": experimental_taintObjectReference for one object, and experimental_taintUniqueValue for a string, number or similar value. They are behind a Next.js flag and, in the official words, the feature "is currently experimental and subject to change, it’s not recommended for production". It is an additional layer, not a replacement for filtering in the DAL.

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    taint: true,
  },
}

export default nextConfig

The documentation adds that enabling the flag also switches the React experimental channel on for the app directory and taints process.env itself, so the whole object cannot be passed to a Client Component. Without the flag, importing the functions fails at build time (the build output prints ⨯ taint for a disabled experiment and ✓ taint for an enabled one):

Error: You're importing `experimental_taintUniqueValue` from React which requires `experimental.taint: true` in your Next.js config. Learn more: https://nextjs.org/docs/app/api-reference/config/next-config-js/taint

The scratch app marks the whole row and, separately, the hash value:

// lib/tainted.ts
import 'server-only'
import { experimental_taintObjectReference, experimental_taintUniqueValue } from 'react'
import { users, type UserRecord } from './db'

export async function getTaintedUserRecord(id: string): Promise<UserRecord | undefined> {
  const row = users.find((u) => u.id === id)
  if (!row) return undefined
  experimental_taintObjectReference(
    'Do not pass the whole user row to the client. Select the fields you need.',
    row
  )
  // Values are tainted for as long as `row` is in scope.
  experimental_taintUniqueValue('Do not pass the password hash to the client.', row, row.pwHash)
  return row
}

A page then tries several ways of passing the data to ProfileCardLoose (a Client Component accepting any object). Built with taint: true and requested with curl:

Table 3. What tainting caught and what it missed (Next.js 16.3.8, observed)
What the Server Component passes Status Observation

the tainted row itself

500

Rejected. The server log shows the message given to experimental_taintObjectReference

the tainted password hash value inside a new object

500

Rejected with the message given to experimental_taintUniqueValue

the hash converted with toLowerCase() (a derived value)

200

Leaked: the response contains the derived string. Tainting does not follow derived data

{ …​row, pwHash: 'x' } (a copy without the tainted value)

200

Leaked: the e-mail and the internal note appear. Tainting tracks objects by reference; a copy is untainted

single fields such as email

200

Allowed by design: you may still read individual fields of a tainted object

The same experiment with process.env gave the built-in protection and its limits:

Table 4. Passing process.env to a Client Component (taint enabled, observed)
What is passed Status Observation

process.env itself

500

The error is "Do not pass process.env to Client Components since it will leak sensitive data"

{ …​process.env }

200

Leaked: the copy is untainted

process.env.SOME_SECRET as a string

200

Leaked: a single variable read as a string is not tracked

Those are the caveats the official page lists: tainting tracks references and unique values, not data derived from them, and a copy loses the guarantee. Treat it as a tripwire that turns some mistakes into loud errors during development and testing, and keep the DAL and the DTOs as the real control.

Server Actions and Route Handlers Are Public Endpoints

The data flows the other way too: input arrives from the browser. A Server Action compiles to an HTTP POST, so it is reachable by anyone who can send that request, whether or not any page links to it; a Route Handler or an API route is public by definition. The Server Actions page covers the mechanics; this section lists the security rules and shows them working.

Framework protections

Next.js adds several protections, none of which replaces your own checks:

  • Encrypted, non-deterministic action IDs. The client refers to an action by an ID that Next.js creates at compile time and recalculates between builds (cached for at most 14 days according to the guide).

  • Dead code elimination. Server Actions that nothing references are removed from the client bundle and get no public endpoint. The guide says plainly that you should still treat every action as reachable by a direct POST.

  • Closure encryption. An inline action may capture variables from the component that defined it; they travel to the browser and back, so Next.js encrypts them, with a new key per action on every build.

  • Origin check against CSRF. Actions only accept POST, and Next.js compares the Origin header to the Host (or X-Forwarded-Host) header and aborts on mismatch. SameSite cookie defaults add a second layer in modern browsers.

The closure encryption was checked directly. An inline action captures a string, and the page source is searched for it:

// app/sec/closure/page.tsx
export default async function Page() {
  await connection()
  const snapshot = 'MARKER_CLOSURE_VALUE' // captured by the inline action below

  async function publish() {
    'use server'
    if (snapshot !== 'MARKER_CLOSURE_VALUE') throw new Error('stale')
  }

  return (
    <form action={publish}>
      <button>Publish</button>
    </form>
  )
}
$ curl -s http://localhost:3802/sec/closure | grep -c MARKER_CLOSURE_VALUE
0

The form carries hidden fields named $ACTION_1:0, $ACTION_1:1 and $ACTION_1:2 with opaque values (redacted here); the captured string is not in the HTML. The guide’s own caution applies: "We don’t recommend relying on encryption alone to prevent sensitive values from being exposed on the client", so do not capture secrets in a closure in the first place.

Checks that belong inside every action

The scratch app has a review-deletion action in the shape the guide recommends: a thin 'use server' file that validates the input and delegates to the DAL, where authentication and authorisation (ownership) are checked.

// app/sec/reviews/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { deleteReview } from '@/lib/dal'

const schema = z.object({ reviewId: z.coerce.number().int().positive() })

export async function deleteReviewAction(formData: FormData): Promise<void> {
  const { reviewId } = schema.parse({ reviewId: formData.get('reviewId') })
  await deleteReview(reviewId) // authentication and ownership are checked in the DAL
  revalidatePath('/sec/reviews')
}
// lib/dal.ts (continued)
export async function deleteReview(reviewId: number) {
  const viewer = await verifySession()
  if (!viewer) throw new Error('Unauthorized')
  const index = reviews.findIndex((r) => r.id === reviewId)
  if (index === -1) throw new Error('Not found')
  if (reviews[index].authorId !== viewer.userId && viewer.role !== 'admin') {
    throw new Error('Forbidden')
  }
  reviews.splice(index, 1)
}

The rendered <form> has an action that the browser replaces with a hidden $ACTION_ID_<id> field. A script can send the same multipart POST without the UI, so the action was called directly with curl, taking the field name from the page source (<id> stands for the build-specific action ID). Two reviews exist, one written by u1 and one by u2:

Table 5. Calling the action directly (Next.js 16.3.8, next start, observed)
Request Status Reviews left afterwards

no session cookie, Origin equal to the host

500

2 (nothing deleted)

session of u2 deleting the review of u1

500

2 (nothing deleted; ownership check)

session of u2, reviewId=abc

500

2 (Zod rejected the input)

session of u1, Origin: https://evil.example

500

2 (Next.js aborted the action: "Invalid Server Actions request")

session of u1 deleting their own review, own origin

200

1

The server log carries the messages (Unauthorized, Forbidden, a Zod error, and "`x-forwarded-host` header with value localhost:3802 does not match origin header with value evil.example from a forwarded Server Actions request. Aborting the action."). In production the browser only sees a generic error, which is what you want. The rules the experiment demonstrates:

  • Authenticate inside the action. A redirect on the page that renders the form controls only what is displayed; the guide’s own example repeats the auth() check inside the inline action and calls it "critical".

  • Authorise against the resource. Checking "is a user logged in" without "does this user own this record" is an Insecure Direct Object Reference (IDOR). The Forbidden row above is that check.

  • Validate every input (FormData, URL parameters, headers, searchParams). The guide’s counter-example trusts searchParams.isAdmin; the fix re-verifies the caller from the cookie every time.

  • Return only what the UI needs. Action return values are serialised to the client, so return { success: true }, not the updated database row.

  • Never mutate while rendering. Cookies cannot be set and caches cannot be revalidated during render, and mutations belong to POST actions, which also removes a CSRF vector.

  • Rate-limit expensive actions (e-mail, writes). The guide points to the rate limiting section of the Backend for Frontend guide; see Route Handlers and Backend for Frontend.

Allowed origins behind proxies

When the public host differs from the host Next.js sees (a reverse proxy that does not forward X-Forwarded-Host, a multi-layer backend), the origin check rejects legitimate requests. serverActions.allowedOrigins lists the extra hosts to accept. Entries match the host of the Origin header (hostname plus port when there is one); stands for exactly one label and * for one or more at the start of a pattern.

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    serverActions: {
      allowedOrigins: ['shop.example.test'], // wildcards: '*.shop.example.test'
    },
  },
}

export default nextConfig

With that entry, the same direct call from curl with Origin: http://shop.example.test was accepted (200, review deleted) while Origin: https://evil.example still failed with 500. A request with no Origin header at all was also accepted, with only a warning in the log (Missing origin header from a forwarded Server Actions request), as the configuration page states, so the origin check is a CSRF defence for browsers and not an authentication mechanism. The check runs in production as well as in development. The option still lives under experimental in 16.3.8.

Self-hosting: the encryption key

By default each build generates new encryption keys. With several server instances (containers behind a load balancer) the instances must share one key, otherwise an action encrypted by one instance cannot be decrypted by another and users get "Failed to find Server Action" errors. Set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY to a base64 value of 16, 24 or 32 bytes, generated, never invented and never committed:

openssl rand -base64 32          # prints a new random key; store it in your secret manager
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="<generated key>" next build

The self-hosting guide notes that the key is embedded in the build output and used at runtime. This is an advanced case; follow normal key rotation practice.

Routes and Proxy deserve the same suspicion

The audit checklist in the guide singles out proxy.ts and route.ts: "have a lot of power". A Route Handler needs the same treatment as an action, and a client-supplied price is a classic bug (one of the consulted books builds Stripe Checkout line items from prices posted by the browser). The scratch app has a quote endpoint that believes only the ids and quantities it receives and looks prices up on the server:

// app/api/quote/route.ts
import { z } from 'zod'
import { getBook } from '@/lib/books'
import { verifySession } from '@/lib/dal'

// The client may send a cart, but only ids and quantities are believed.
const cart = z.array(
  z.object({
    bookId: z.string().min(1),
    quantity: z.number().int().min(1).max(10),
  }) // unknown keys such as priceCents are stripped, never trusted
).min(1).max(50)

export async function POST(request: Request) {
  const session = await verifySession()
  if (!session) return Response.json({ error: 'unauthorized' }, { status: 401 })

  const parsed = cart.safeParse(await request.json().catch(() => null))
  if (!parsed.success) return Response.json({ error: 'invalid cart' }, { status: 400 })

  let totalCents = 0
  for (const line of parsed.data) {
    const book = await getBook(line.bookId) // the price comes from the server
    if (!book) return Response.json({ error: `unknown book ${line.bookId}` }, { status: 400 })
    totalCents += book.priceCents * line.quantity
  }
  return Response.json({ totalCents })
}
$ curl -s -w ' [%{http_code}]\n' -X POST -H 'content-type: application/json' \
    -d '[{"bookId":"1","quantity":2}]' http://localhost:3802/api/quote
{"error":"unauthorized"} [401]
$ curl -s -w ' [%{http_code}]\n' -X POST -H 'content-type: application/json' -b demo-user=u1 \
    -d '[{"bookId":"1","quantity":2,"priceCents":1}]' http://localhost:3802/api/quote
{"totalCents":1998} [200]
$ curl ... -b demo-user=u1 -d '[{"bookId":"1","quantity":-5}]'
{"error":"invalid cart"} [400]

The tampered priceCents: 1 was ignored: the total is two copies at the server-side price of 999. Quantities are bounded (both consulted books omit that validation), and malformed JSON gives 400 instead of an unhandled exception. Whether a payment succeeded must likewise come from the payment provider’s webhook, not from the browser returning to a success URL; that belongs to the commerce examples, not to this page.

Proxy is a convenient place for an optimistic check (is there a session cookie at all?), but it must not be the only gate. The July 2026 security release below includes a Proxy bypass: authentication logic that lived only in Proxy was skipped for affected setups.

Environment Variables: What the Browser Can Read

By default an environment variable exists only in the Node.js process. Next.js copies a variable into client code only if its name starts with NEXT_PUBLIC_, and it does so by inlining the value into the JavaScript bundle at build time, replacing every process.env.NEXT_PUBLIC_NAME expression with the literal string. The configuration page explains load order and the build-time versus runtime distinction; this section is about the security consequence: a NEXT_PUBLIC_ value is published to every visitor.

The experiment used a scratch .env.local (never committed) with two markers, one public and one private:

# .env.local, scratch values only
NEXT_PUBLIC_SHOP_NAME=MARKER_PUBLIC_SHOP
SHOP_ADMIN_TOKEN=MARKER_PRIVATE_TOKEN
// components/env-badge.tsx
'use client'

export function EnvBadge() {
  // Inlined at build time into the client bundle.
  return <p>{process.env.NEXT_PUBLIC_SHOP_NAME}</p>
}
// app/sec/env/page.tsx
import { connection } from 'next/server'
import { EnvBadge } from '@/components/env-badge'

export default async function Page() {
  await connection()
  const configured = Boolean(process.env.SHOP_ADMIN_TOKEN) // server only
  return (
    <>
      <EnvBadge />
      <p>admin token configured: {String(configured)}</p>
    </>
  )
}

After next build, the build output was searched:

$ grep -rl MARKER_PUBLIC_SHOP  .next/static | wc -l     # files downloaded by browsers
1
$ grep -rl MARKER_PRIVATE_TOKEN .next/static | wc -l
0
$ grep -rl MARKER_PRIVATE_TOKEN .next/server | wc -l    # server output: read at request time
0

The public value is in a static chunk that any visitor can download; the private one is in neither the client nor the server output, because the page reads process.env at request time (after connection()), in the server process only.

Rules

  • Only public values get NEXT_PUBLIC_: an analytics ID, a Stripe publishable key, a public API base URL. Anything that grants write access, charges money or identifies an account holder does not.

  • The prefix is a decision, not a naming style. The names the consulted books use for secrets are exactly the ones that must never exist. In the Apress e-commerce book a Sanity write token is stored as NEXT_PUBLIC_SANITY_TOKEN and a Stripe secret key as NEXT_PUBLIC_STRIPE_SECRET_KEY, with the explanation that the .env file keeps them private. It does not: the prefix publishes them. The correct form is SANITY_WRITE_TOKEN and STRIPE_SECRET_KEY, read with process.env only in server-only code (the DAL), with the browser talking to your server instead of to the third party.

  • Dynamic lookups are not inlined. process.env[name] and const env = process.env; env.NEXT_PUBLIC_X are not replaced, so they are undefined in the browser (a functional bug, not a protection to rely on).

  • Public values are frozen at build time. Promoting one Docker image across environments keeps the values of the build. Per-environment client configuration needs your own endpoint or a request-time read on the server.

  • .env files stay out of version control and out of images.* The create-next-app template adds them to .gitignore, and the multiline example in the guide is a reminder that private keys also live here. Keep real values in a secret manager and .env.example in the repository with placeholders.

  • Only the DAL reads process.env for secrets, as the guide advises, so a search shows every use.

  • Do not hard-code secrets in server code either. The December 2025 React Server Components advisory included a bug where a crafted request could make a Server Function return the compiled source of other Server Functions; the advisory noted that secrets written directly into code (rather than read from environment variables at runtime) might be exposed that way.

Cross-Site Scripting Traps

React escapes text and attribute values it renders, which is why most Next.js pages are safe from classic injection. The remaining holes are the places that bypass that escaping: URLs that execute code, raw HTML, and JSON embedded in an inline script.

The javascript: URL pitfall

A link whose href is javascript:…​ runs code in your page when clicked. If any part of a URL comes from a query string, a database field or user content (a profile "website", a ?next= parameter), that is script injection. Three layers apply in Next.js 16.3.8, and none of them should be your only defence:

  • React 19 refuses to render the URL. The page below renders <a href={u}> from a query parameter. Requesting it with u=javascript:alert(1) produced this attribute in the server HTML:

    <a id="raw" href="javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')">

    The dangerous URL was replaced by code that throws, so clicking does nothing harmful.

  • The router refuses it too. The official useRouter documentation warns against passing untrusted URLs to router.push or router.replace, because a javascript: URL "will be executed in the context of your page". On 16.3.8 the installed router throws on such a URL; the Linking and Navigation page shows that experiment and a toSafePath helper that reduces any target to a same-origin path (which also stops open redirects).

  • Your own validation. Accept only URLs whose scheme you list, for example https: and mailto: for user-supplied links, and only same-origin paths for a post-login redirect. Rejecting javascript: alone is not enough, because data: and other schemes and open redirects to attacker sites are separate problems.

// app/sec/xss/page.tsx (excerpt)
export default async function Page({ searchParams }: { searchParams: Promise<{ u?: string }> }) {
  await connection()
  const { u = '/' } = await searchParams
  return <a id="raw" href={u}>raw link</a>
}

dangerouslySetInnerHTML and JSON in inline scripts

dangerouslySetInnerHTML skips escaping by design; sanitise any HTML you did not write with a maintained sanitiser. The most common accidental use is structured data. JSON-LD is a script element whose contents must not contain </script>, and a title that comes from user content can. The Next.js documentation for JSON-LD recommends replacing < with its Unicode escape. The page compared both ways with a hostile title (</script><script>alert(1)</script>):

// Serialise JSON for an inline <script>: escape "<" so "</script>" cannot appear.
const safeJson = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c')

const jsonLd = { '@context': 'https://schema.org', '@type': 'Book', name: t }

<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: safeJson(jsonLd) }} />
<!-- plain JSON.stringify: the string closes the script element and the next one starts -->
<script id="ld-bad" type="application/ld+json">{"@context":"https://schema.org","@type":"Book","name":"</script>
<!-- safeJson: the angle bracket never appears -->
<script id="ld-good" type="application/ld+json">{"@context":"https://schema.org","@type":"Book","name":"</script><script>alert(1)</script>"}</script>

The unescaped version really terminated the element at the injected </script>. The metadata page of this section (JSON-LD structured data) applies the same escape.

Content Security Policy

A Content Security Policy (CSP) is a response header that tells the browser which sources of scripts, styles, images, fonts, frames and connections the page may use. It cannot fix an injection bug, but it makes a successful injection much less useful: an injected <script> that is not allowed does not run. The official guide describes two ways to get a strict script policy in Next.js, a nonce per request, or hashes through Subresource Integrity; and a third, simpler, weaker policy that allows inline scripts.

Nonces, and why they need dynamic rendering

A nonce is a random, single-use value. The policy says "scripts are allowed if they carry nonce X", and the server puts X on every script it emits. An attacker who injects a script cannot guess X, because it changes on every request. The policy in the guide (an example for Proxy) is:

  • script-src 'self' 'nonce-…​' 'strict-dynamic': scripts with the nonce run, and scripts those scripts load also run (strict-dynamic); host allow-lists are then ignored by the browser.

  • style-src 'self' 'nonce-…​', default-src 'self', object-src 'none', base-uri 'self', form-action 'self', frame-ancestors 'none' (clickjacking) and upgrade-insecure-requests.

Because the nonce must be fresh for each request, the page must be rendered per request. Next.js reads the nonce from the request’s Content-Security-Policy header while it renders on the server and attaches it to its framework scripts, page bundles, inline scripts and styles it generates, and any <Script nonce>. A statically generated page is built when there is no request, so there is no nonce to attach.

The Proxy

// proxy.ts
import { NextRequest, NextResponse } from 'next/server'

export function proxy(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
  const isDev = process.env.NODE_ENV === 'development'
  const csp = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ''};
    style-src 'self' 'nonce-${nonce}';
    img-src 'self' blob: data:;
    font-src 'self';
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    upgrade-insecure-requests;
  `
    .replace(/\s{2,}/g, ' ')
    .trim()

  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-nonce', nonce)
  requestHeaders.set('Content-Security-Policy', csp)

  const response = NextResponse.next({ request: { headers: requestHeaders } })
  response.headers.set('Content-Security-Policy', csp)
  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' },
      ],
    },
  ],
}

The nonce is set on the request headers (so the renderer can read it, and so Server Components can read x-nonce) and the policy on the response headers (so the browser enforces it). The matcher skips API routes, static assets and prefetch requests, which do not need the header. In development React uses eval to rebuild server error stacks, so 'unsafe-eval' is needed there and not in production. Proxy replaced middleware in Next.js 16; the Redirects, Rewrites and Proxy page covers the file convention.

A dynamic page, and a component that reads the nonce for a third-party script:

// app/csp/page.tsx (previous model)
import { connection } from 'next/server'
import { headers } from 'next/headers'
import Script from 'next/script'

export default async function Page() {
  await connection() // nonces need per-request rendering
  const nonce = (await headers()).get('x-nonce') ?? undefined
  return (
    <main>
      <h1>CSP demo</h1>
      <Script id="inline-demo" nonce={nonce} strategy="afterInteractive">
        {`document.documentElement.dataset.nonceOk = 'yes'`}
      </Script>
    </main>
  )
}

next build lists the page as ƒ /csp (dynamic). Requested with curl -i:

$ curl -s -i http://localhost:3802/csp -o csp.out
$ grep -i '^content-security-policy' csp.out
content-security-policy: default-src 'self'; script-src 'self' 'nonce-<redacted>' 'strict-dynamic'; style-src 'self' 'nonce-<redacted>'; img-src 'self' blob: data:; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests;
$ grep -o '<script' csp.out | wc -l
11
$ grep -o '<script[^>]*nonce=' csp.out | wc -l
11

All 11 script elements, framework chunks and inline bootstrap scripts alike, carry the nonce although the page never mentions it for them, and the nonce in the elements equals the one in the header. A second request received a different nonce. In a real browser the page ran without any CSP violation and the inline demo script executed (dataset.nonceOk was yes).

What breaks: static pages and Cache Components

The same policy sent with a page that is not rendered per request is a trap. The page below is a plain static page (○ /csp-static in the route table), but the Proxy matcher still adds the header:

$ curl -s -i http://localhost:3802/csp-static -o st.out
$ grep -i '^content-security-policy' st.out | cut -c1-120
content-security-policy: default-src 'self'; script-src 'self' 'nonce-<redacted>' 'strict-dynamic'; ...
$ echo "scripts with nonce: $(grep -o '<script[^>]*nonce=' st.out | wc -l) of $(grep -o '<script' st.out | wc -l)"
scripts with nonce: 0 of 10

The scripts were generated at build time and have no nonce, so with strict-dynamic the browser blocks them all. In the browser the console filled with "Loading the script …​ violates the following Content Security Policy directive" and "Executing inline script violates …​", and the page stayed unhydrated. Pages that must keep a nonce policy have to be dynamic: call connection() or another request-time API, or use getServerSideProps in the Pages Router.

Cache Components. The official guide states that Partial Prerendering is incompatible with nonce-based CSP, because the static shell cannot know the nonce. This was reproduced with cacheComponents: true:

  • The page above, with await connection() at the top of the component, fails next build with the blocking-prerender-dynamic error ("Next.js encountered uncached or runtime data during prerendering"), because connection() outside a <Suspense> boundary is not allowed under that model.

  • With the dynamic part moved into a component under <Suspense>, the build succeeds and the route is listed as ◐ /csp (Partial Prerender). The response then contained 14 script elements and only 4 had a nonce (the ones rendered in the dynamic part); the 10 in the prerendered shell had none. In a real browser the Next.js client runtime did not start (window.next was undefined) and the inline demo script never ran.

// app/csp/page.tsx (Cache Components: the dynamic part is under Suspense)
import { Suspense } from 'react'
import { connection } from 'next/server'
import { headers } from 'next/headers'
import Script from 'next/script'

async function NonceScript() {
  await connection() // per-request work: the nonce only exists at request time
  const nonce = (await headers()).get('x-nonce') ?? undefined
  return (
    <Script id="inline-demo" nonce={nonce} strategy="afterInteractive">
      {`document.documentElement.dataset.nonceOk = 'yes'`}
    </Script>
  )
}

export default function Page() {
  return (
    <main>
      <h1>CSP demo</h1>
      <Suspense fallback={null}>
        <NonceScript />
      </Suspense>
    </main>
  )
}

So with Cache Components, a nonce policy works only if the whole route (shell included) is rendered per request, which defeats the point of the model for that route. Choose between a nonce policy for the application and the prerendered shell; or use the hash-based approach below. A nonce policy also disables static optimisation and ISR for the affected routes and prevents CDN caching of their HTML without extra configuration, as the guide lists under "Performance Implications".

Third-party scripts follow the same rule: pass the nonce (<Script nonce={nonce}>, or the nonce prop of @next/third-parties components such as GoogleTagManager) and add the domains to script-src (and connect-src or img-src for the analytics endpoints). The images, fonts and scripts page covers the Script component.

Without nonces: a policy in next.config

Applications that do not need a nonce can send a static policy from next.config; the cost is 'unsafe-inline' for scripts and styles, which weakens the protection against injected inline scripts, and the benefit is that pages stay static. Remember that 'unsafe-inline' still stops loading scripts from other origins and everything else in the policy (framing, form targets, base URI, plugins) still applies.

// next.config.ts
import type { NextConfig } from 'next'

const isDev = process.env.NODE_ENV === 'development'

const csp = `
  default-src 'self';
  script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ''};
  style-src 'self' 'unsafe-inline';
  img-src 'self' blob: data:;
  font-src 'self';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';
  upgrade-insecure-requests;
`.replace(/\s{2,}/g, ' ').trim()

const nextConfig: NextConfig = {
  async headers() {
    return [{ source: '/:path*', headers: [{ key: 'Content-Security-Policy', value: csp }] }]
  },
}

export default nextConfig

Subresource Integrity (experimental)

Next.js 14 added experimental hash-based support: with experimental.sri, the build computes a hash of every script it emits and adds an integrity attribute to the script tag, so the browser can verify the file. Pages can stay static and cacheable, which is the point. The guide lists the limits: it is experimental, App Router only, and cannot cover dynamically generated scripts. The algorithm is sha256, sha384 or sha512.

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    sri: { algorithm: 'sha256' },
  },
}

export default nextConfig

Built with sri enabled and requested with curl, the static page’s external scripts had integrity attributes (the digest is shown redacted):

$ grep -o '<script' st.out | wc -l
10
$ grep -o '<script[^>]*integrity=' st.out | wc -l
7
$ grep -o '<script[^>]*integrity="[^"]*"' st.out | head -1
<script src="..." async="" integrity="sha256-<redacted>"

Seven of the ten script elements got an integrity attribute; the other three are inline scripts, which SRI does not cover. That matters for the policy: the guide’s example for SRI uses script-src 'self' with no 'unsafe-inline', and with exactly that header, in the browser on this version, the inline scripts Next.js emits (the data the client needs to hydrate) were blocked ("Executing inline script violates the following Content Security Policy directive 'script-src 'self''") and hydration failed with React error 412. Adding 'unsafe-inline' to script-src made the page boot (window.next.version was 16.3.8, seven scripts with integrity). So, for static pages with this release, plan either for 'unsafe-inline' (and treat SRI as integrity checking of the files, not as a strict XSS policy) or for nonces on dynamic routes, and test the real headers in a browser before shipping. The guide also says SRI and nonces can be combined for dynamic pages.

Table 6. Choosing a CSP strategy
Strategy Strict against injected inline scripts Static pages and CDN Notes

Nonce from Proxy

Yes

No: every route must render per request; incompatible with Partial Prerendering

Needs connection() or request APIs; extra server load; pass the nonce to third-party scripts

Static policy with 'unsafe-inline'

No (still blocks foreign origins and the other directives)

Yes

Simplest; good first step

SRI (experimental.sri)

Integrity of external files, not inline code

Yes

Experimental, App Router only; test the inline-script behaviour described above

A policy can be introduced gradually: browsers also accept Content-Security-Policy-Report-Only, which reports violations without blocking anything (a general CSP feature described by MDN; it was not exercised for this page). Check the production policy against the real pages, including images, fonts, analytics and embedded content, because the common failures listed in the guide are blocked static assets, an inline style, a dynamic import, WebAssembly ('wasm-unsafe-eval') and service workers.

Pages Router. The Pages Router has its own copy of the guide, with the same Proxy example. The same rule holds there: only pages rendered per request can receive a nonce, and SRI is App Router only.

Security Headers

CSP is one of several response headers that make the browser stricter. They are set for all routes either with the headers key of next.config (static values, applied before the filesystem, so they also cover pages and public files) or in Proxy (per request values such as the nonce). If two entries match the same path and set the same key, the last one wins. The documentation lists these options:

Table 7. Security headers from the Next.js documentation
Header Value in the documentation Effect

Strict-Transport-Security

max-age=63072000; includeSubDomains; preload

Tells browsers to use HTTPS only. Add preload only when you are ready for the browser preload list, which is hard to undo

X-Content-Type-Options

nosniff

The browser must respect the declared Content-Type instead of guessing

Referrer-Policy

origin-when-cross-origin

Limits what URL information leaves the site. strict-origin-when-cross-origin is a common, stricter choice

Permissions-Policy

camera=(), microphone=(), geolocation=(), browsing-topics=()

Disables browser features the site does not use

X-Frame-Options

SAMEORIGIN

Superseded by CSP frame-ancestors; keep only for old browsers

X-DNS-Prefetch-Control

on

Controls DNS prefetching

A configuration built and verified:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  poweredByHeader: false, // do not advertise the framework
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          { key: 'X-Content-Type-Options', value: 'nosniff' },
          { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
          { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
          { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains' },
          // plus the static Content-Security-Policy shown earlier
        ],
      },
    ]
  },
}

export default nextConfig
$ curl -s -i http://localhost:3802/csp-static | sed -n '1,/^\r*$/p' | grep -i 'x-content\|referrer\|permissions\|strict-transport\|x-powered'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Strict-Transport-Security: max-age=63072000; includeSubDomains

All four headers were present and, with poweredByHeader: false, there was no X-Powered-By. HSTS is meaningful only over HTTPS; browsers ignore it on plain HTTP, so local curl shows the header but only a TLS-terminating deployment exercises it. CORS is a separate topic (the Access-Control-* headers belong to the Route Handler or headers entries of the API routes involved); see CORS for the browser rules.

Source: headers and poweredByHeader

The Pages Router Equivalent

The rules are the same; the shapes differ.

  • Props are the leak. Everything returned in props from getServerSideProps or getStaticProps is serialised into the HTML as JSON in the NEXT_DATA script. A page that returned a whole row exposed it all:

    // pages/pl.tsx
    export const getServerSideProps: GetServerSideProps<Props> = async () => {
      const row = { name: 'Ada', email: 'ada@example.com', pwHash: 'PLACEHOLDER_HASH_1' }
      // BAD: the whole row becomes page props, and props are serialised into the HTML.
      return { props: { user: row } }
    }
    $ curl -s http://localhost:3802/pl | grep -o '"pageProps":{[^}]*}}'
    "pageProps":{"user":{"name":"Ada","email":"ada@example.com","pwHash":"PLACEHOLDER_HASH_1"}}

    Select the fields in getServerSideProps (a DTO function) exactly as in the App Router.

  • Server code in getServerSideProps is removed from the client bundle by the compiler, but a shared helper module is not automatically server-only; keep database and secret access in modules that only the data functions import, and enforce that boundary yourself (a server/ folder, lint rules).

  • API routes (pages/api) are public endpoints: authenticate, authorise and validate inside each. There is no Server Action origin check for them; use CORS rules and SameSite cookies deliberately.

  • CSP with nonces works for server-rendered pages only, as above. taint and SRI are App Router features.

  • Environment variables behave identically (NEXT_PUBLIC_ inlined at build time).

Why Patch Releases Matter: The September 2026 Security Release

Next.js is a framework that sits at the edge of your application, so vulnerabilities in it are vulnerabilities in every site built on it. The data-security rules above reduce the damage a bug can do, but they cannot remove the bug. The project’s own record from 2025 and 2026 is a practical argument for upgrading promptly:

  • December 2025. A critical flaw in the React Server Components protocol (React’s CVE-2025-55182, tracked for Next.js as CVE-2025-66478, rated CVSS 10.0) allowed remote code execution through crafted requests against unpatched App Router applications; the Next.js advisory advised rotating secrets for apps that were online and unpatched on a given date. Two follow-up advisories a week later covered denial of service and source code exposure (the latter is why secrets must come from the environment, not from code).

  • July 2026. The first release under the new, pre-announced program (see below) shipped 16.2.11 and 15.5.21 with fixes for four high and several medium severity issues, including a denial of service in Server Actions, a Proxy and middleware bypass in a Turbopack single-locale setup, and server-side request forgery in rewrites() and in Server Actions on custom servers.

  • August 2026. Releases 16.3.3 and 15.5.24 addressed two critical issues: remote code execution in the Image Optimization API through AVIF (a flaw in an underlying library; the patched releases disable AVIF optimisation until an upstream fix is available) and remote code execution on Windows-hosted servers in some mixed-router setups.

  • September 22, 2026. An out-of-band update (16.3.6 and 15.5.26) fixed a critical remote code execution issue in ImageResponse caused by an upstream dependency.

  • September 30, 2026. The scheduled September release, 16.3.8 (Active LTS) and 15.5.27 (Maintenance LTS), the baseline of this page.

The September 30 release deserves a closer look because it shows how varied the issues are. According to the official announcement it fixed one high, five medium and one low severity vulnerability (a critical and a high one that had been announced were postponed to a later release because of upstream dependency delays, which the pre-announcement was updated to say):

Table 8. The September 2026 security release (from the official announcement)
Issue Severity Who is affected

Server-side request forgery in Image Optimization (CVE-2026-94483)

High

Applications with images.remotePatterns; an allow-listed remote URL could lead to requests to private IP ranges. Without remotePatterns the application is not affected

Cache poisoning of SSG and ISR pages (CVE-2026-94543)

Medium

Self-hosted Pages Router applications using SSG or ISR; not applications deployed on Vercel

Cache poisoning in SSG/ISR rendering with a root-level catch-all page (CVE-2026-94484)

Medium

Applications with a root-level catch-all page together with static or ISR routes

Information disclosure in metadata image routes (CVE-2026-94485)

Medium

App Router applications built with webpack (opengraph-image, twitter-image ignoring dynamicParams); not applications built with Turbopack

Cache leak across root param values in nested 'use cache' functions (GHSA-h694-7cp9-m8p3; the blog lists no CVE, the GitHub advisory assigns CVE-2026-103004)

Medium

Applications with Cache Components that nest 'use cache' functions reading a root param

Draft Mode content leaking through a pending use cache fill (CVE-2026-94544)

Medium

Applications with Cache Components (or experimental.useCache) that serve Draft Mode previews

Information disclosure in the development server’s MCP endpoint (CVE-2026-94486)

Low

Only applications run with next dev; production does not serve the endpoint

What the release teaches, beyond "update":

  • The affected sets differ by configuration (bundler, router, hosting, Cache Components, Draft Mode, remotePatterns), so the advisory text, not the headline, tells you whether you are exposed. Several of these only affect people who turned on a specific feature, which is also an argument for enabling only what you use.

  • Two of the issues are about caching and Cache Components, the model this section teaches in parallel with the previous one; caching bugs become data-exposure bugs because shared cache entries cross users.

  • Defence in depth matters: the DAL, per-action checks and narrow DTOs limit what a bug elsewhere can expose. The July Proxy bypass is why "authentication in Proxy only" is not enough.

  • Even development has an attack surface: a malicious web page visited while next dev runs could read data from the MCP endpoint.

Next.js also moved to a formal security release program in July 2026: roughly monthly pre-announced releases with the expected timeline and the highest severity, with ad-hoc patches reserved for urgent or already-exploited issues. The announcements and releases are on the blog, which is the place to follow.

npm view next dist-tags            # the latest tag of each line
npm install next@16.3.8            # the patched 16.3 release (15.5.27 for the 15.5 line)
npx next upgrade                   # the upgrade helper (added in 16.1) moves a project to the latest version
npm audit                          # known advisories in all dependencies, not only next

A practical routine: stay on a supported line (16.x is Active LTS and 15.x is Maintenance LTS in the support policy; 14.x and older receive no fixes), commit the lockfile so that deployments use exactly what you tested, update next, react and react-dom together (React Server Components bugs are fixed upstream in React as well), and rebuild and redeploy rather than only upgrading in a development checkout.

Pitfalls

Table 9. Common data security and CSP mistakes
Mistake Fix

Passing a whole database row to a Client Component

Return a DTO from the DAL; type Client Component props narrowly

NEXT_PUBLIC_ on a token, a secret key or a write credential

Remove the prefix, rotate the credential, read it on the server only

Checking the session on the page but not in the Server Action or Route Handler

Authenticate and authorise inside every entry point, in the DAL

Authentication only in Proxy

Treat Proxy as an optimistic check; verify near the data

Trusting client-sent prices, roles, ids or searchParams flags

Look values up on the server and validate with a schema

Relying on taint, closure encryption or action ID secrecy

They are extra layers; filter data and check callers

A nonce CSP on pages that are static or under Cache Components

Render the route per request, or choose a static policy or SRI

'unsafe-eval' copied into the production policy

Only in development; production does not need it

script-src 'self' with SRI on static pages and inline scripts blocked

Test the exact headers in a browser; allow inline scripts or use nonces

javascript: or other untrusted URLs in href, router.push or redirect

Allow-list schemes, reduce redirects to same-origin paths

JSON.stringify straight into an inline <script>

Escape < as <

Multiple instances without a shared NEXT_SERVER_ACTIONS_ENCRYPTION_KEY

Generate one key, store it as a secret, build with it

Staying on an old patch because the application works

Follow the security announcements and upgrade on the announced date

What Changed Recently

Table 10. Security-relevant changes across Next.js versions
Version Change

Next.js 13.4.20

The CSP guide recommends this version for correct nonce handling and header parsing

Next.js 14 (2023-10-26)

Experimental Subresource Integrity (experimental.sri) for hash-based CSP

Next.js 15 (2024-10-21)

Server Action IDs become encrypted and non-deterministic, and unused actions are removed from the client bundle. cookies(), headers() and params become asynchronous: nonce code uses (await headers()).get('x-nonce')

React 19.3 (2026-09-09)

javascript: URLs in rendered href attributes are replaced with code that throws (observed with React 19.3.0; I did not check which React release introduced it)

December 2025

React Server Components security advisories (CVE-2025-66478 and two follow-ups); patched releases of every supported line

Next.js 16 (2025-10-21)

middleware becomes proxy (the nonce example is now proxy.ts and exports proxy)

Next.js 16.1 (2025-12-18)

next upgrade command

July 2026

Formal, pre-announced security release program; first release on 16.2.11 and 15.5.21

Next.js 16.3 (2026-08-03)

Cache Components and partial prefetching remain opt-in; nonce CSP and Partial Prerendering remain incompatible per the guide

Next.js 16.3.8 (2026-09-30)

September 2026 security release (with 15.5.27 for the 15.5 line)

The documentation also still labels experimental.taint, experimental.sri and serverActions.allowedOrigins as experimental, or keeps them under the experimental key, in 16.3.8.

Where the Books Are Outdated

Both consulted books (listed in the section bibliography) predate Server Components and Server Actions, so neither has a data-security model for them, and they do not teach the permanent habits this page expects. Where they touch security, what to do today:

  • Secrets in NEXT_PUBLIC_ variables. The Apress e-commerce book names a Sanity write token NEXT_PUBLIC_SANITY_TOKEN and a Stripe secret key NEXT_PUBLIC_STRIPE_SECRET_KEY and silences the warning; both are published to the browser by the prefix. Use un-prefixed names, read them in server-only code, and rotate any key that was ever shipped. The publishable Stripe key and project IDs are public by design and may use the prefix.

  • Prices and quantities from the client. Checkout line items built from a posted cart let a customer set the price. Look prices up by ID on the server, as the quote example does, and validate quantities.

  • Payment success as proof. The redirect to a success page is not proof of payment; the provider’s webhook is.

  • Authorisation in the client. Protecting a page with a client hook does not protect the endpoint it calls; check on the server, per request.

  • Data functions. getServerSideProps and getStaticProps return props that are serialised into the page; their habit of returning raw rows has the same effect as the Server Component leak above, in NEXT_DATA.

  • Content Security Policy. Neither book describes the nonce and Proxy based approach of the current guide, which is written for the App Router and per-request rendering.

The requester’s books are listed in the section bibliography; no text, listing or figure of them is reproduced here.

Where to Go Next

The authentication and authorisation page of this section builds the session functions this page assumes, including Auth.js, the optimistic Proxy check against the authoritative check near the data, forbidden() and unauthorized(), and the cookie flags. The Server Actions and Mutations page has the full mechanics of actions, and Caching explains why shared cache entries must never hold per-user data. Testing and observability pages later in the section show how to test the access rules and to log failed checks.