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.
| 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, |
|
A secret is bundled into JavaScript that every visitor downloads |
Read secrets only on the server; only genuinely public values get the |
|
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 |
|
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 |
|
Weak browser defaults (framing, sniffing, plain HTTP, referrer leaks) |
Response headers set in |
|
A known vulnerability in Next.js itself |
Staying on the latest patch of a supported line |
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.
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.
| 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 |
Data Access Layer (DAL) |
New projects (recommended) |
One internal, |
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:
| What the Server Component passes | Status | Observation |
|---|---|---|
the tainted row itself |
500 |
Rejected. The server log shows the message given to |
the tainted password hash value inside a new object |
500 |
Rejected with the message given to |
the hash converted with |
200 |
Leaked: the response contains the derived string. Tainting does not follow derived data |
|
200 |
Leaked: the e-mail and the internal note appear. Tainting tracks objects by reference; a copy is untainted |
single fields such as |
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:
| What is passed | Status | Observation |
|---|---|---|
|
500 |
The error is "Do not pass process.env to Client Components since it will leak sensitive data" |
|
200 |
Leaked: the copy is untainted |
|
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 theOriginheader to theHost(orX-Forwarded-Host) header and aborts on mismatch.SameSitecookie 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:
| Request | Status | Reviews left afterwards |
|---|---|---|
no session cookie, |
500 |
2 (nothing deleted) |
session of |
500 |
2 (nothing deleted; ownership check) |
session of |
500 |
2 (Zod rejected the input) |
session of |
500 |
2 (Next.js aborted the action: "Invalid Server Actions request") |
session of |
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
Forbiddenrow above is that check. -
Validate every input (
FormData, URL parameters, headers,searchParams). The guide’s counter-example trustssearchParams.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
POSTactions, 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_TOKENand a Stripe secret key asNEXT_PUBLIC_STRIPE_SECRET_KEY, with the explanation that the.envfile keeps them private. It does not: the prefix publishes them. The correct form isSANITY_WRITE_TOKENandSTRIPE_SECRET_KEY, read withprocess.envonly 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]andconst env = process.env; env.NEXT_PUBLIC_Xare not replaced, so they areundefinedin 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.
-
.envfiles stay out of version control and out of images.* Thecreate-next-apptemplate 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.examplein the repository with placeholders. -
Only the DAL reads
process.envfor 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 withu=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
useRouterdocumentation warns against passing untrusted URLs torouter.pushorrouter.replace, because ajavascript: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 atoSafePathhelper 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:andmailto:for user-supplied links, and only same-origin paths for a post-login redirect. Rejectingjavascript:alone is not enough, becausedata: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.
Source: useRouter: good to know and JSON-LD and React: dangerouslySetInnerHTML
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) andupgrade-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, failsnext buildwith theblocking-prerender-dynamicerror ("Next.js encountered uncached or runtime data during prerendering"), becauseconnection()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.nextwas 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.
| 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 |
Static policy with |
No (still blocks foreign origins and the other directives) |
Yes |
Simplest; good first step |
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:
| Header | Value in the documentation | Effect |
|---|---|---|
|
|
Tells browsers to use HTTPS only. Add |
|
|
The browser must respect the declared |
|
|
Limits what URL information leaves the site. |
|
|
Disables browser features the site does not use |
|
|
Superseded by CSP |
|
|
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
propsfromgetServerSidePropsorgetStaticPropsis serialised into the HTML as JSON in theNEXT_DATAscript. 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
getServerSidePropsis 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 (aserver/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 andSameSitecookies deliberately. -
CSP with nonces works for server-rendered pages only, as above.
taintand SRI are App Router features. -
Environment variables behave identically (
NEXT_PUBLIC_inlined at build time).
Source: getServerSideProps and Pages Router: API Routes
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
ImageResponsecaused 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):
| Issue | Severity | Who is affected |
|---|---|---|
Server-side request forgery in Image Optimization (CVE-2026-94483) |
High |
Applications with |
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 ( |
Cache leak across root param values in nested |
Medium |
Applications with Cache Components that nest |
Draft Mode content leaking through a pending |
Medium |
Applications with Cache Components (or |
Information disclosure in the development server’s MCP endpoint (CVE-2026-94486) |
Low |
Only applications run with |
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 devruns 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.
Source: September 2026 Security Release and August 2026 Security Release and July 2026 Security Release and Next.js Security Release and Our Next Patch Release and Security Update for a Critical Upstream Issue and Security Advisory: CVE-2025-66478 and Support policy and Next.js security advisories on GitHub
Pitfalls
| Mistake | Fix |
|---|---|
Passing a whole database row to a Client Component |
Return a DTO from the DAL; type Client Component props narrowly |
|
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 |
Look values up on the server and validate with a schema |
Relying on |
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 |
|
Only in development; production does not need it |
|
Test the exact headers in a browser; allow inline scripts or use nonces |
|
Allow-list schemes, reduce redirects to same-origin paths |
|
Escape |
Multiple instances without a shared |
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
| 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 ( |
Next.js 15 (2024-10-21) |
Server Action IDs become encrypted and non-deterministic, and unused actions are removed from the client bundle. |
React 19.3 (2026-09-09) |
|
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) |
|
Next.js 16.1 (2025-12-18) |
|
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 tokenNEXT_PUBLIC_SANITY_TOKENand a Stripe secret keyNEXT_PUBLIC_STRIPE_SECRET_KEYand 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.
getServerSidePropsandgetStaticPropsreturn props that are serialised into the page; their habit of returning raw rows has the same effect as the Server Component leak above, inNEXT_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.