Cyber Security

Next.js Security: Where the Authorisation Check Belongs

Hardening your Next.js application against common web vulnerabilities

Middleware is not an authorisation boundary — CVE-2025-29927 proved it with one header. Where to put the check so a page, a route handler and a Server Action are all covered, what leaks into the RSC payload, and the CSP choice that costs you static rendering.

Most Next.js security guides are a list of response headers and a reminder that NEXT_PUBLIC_ is public. Both are true and neither is what goes wrong. The failures that actually reach production in Next.js applications come from one place: the framework makes the boundary between server and client almost invisible, and almost invisible boundaries are the ones people put their authorisation checks on the wrong side of.

This is about that boundary — where to put the check so it cannot be skipped, what leaks across it when you are not looking, and the one CSP decision that quietly costs you every page's static rendering.

Middleware is not an authorisation boundary

The standard advice is to authenticate in middleware.ts, because it runs at the edge before any page renders. It is also the advice that produced CVE-2025-29927, disclosed in March 2025 and rated 9.1 critical.

The bug was this: Next.js used an internal header, x-middleware-subrequest, to stop middleware recursing into itself. A request that arrived from outside carrying that header was treated as an internal subrequest, and middleware was skipped entirely. Any application whose only access check lived in middleware served its protected pages to anyone who added one header.

bash
curl -H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware" \
  https://example.com/admin

It is patched — 15.2.3, 14.2.25, 13.5.9 and 12.3.5 — and if you are on a supported release you have the fix. Upgrading is not the lesson.

The lesson is that middleware was structurally the wrong place for the decision, and would have been even if the header bug had never existed. Middleware runs before routing is resolved, before the page knows which record it is about to read, and with no access to your database. It can answer "is there a plausible session cookie", which is a routing question. It cannot answer "may this user read this row", which is the authorisation question. Anything that sits that far from the data is a redirect, not a control.

Put middleware to work on what it is good at — sending signed-out visitors to the login page so they do not see a flash of an empty dashboard — and put the real check next to the data:

ts
// lib/dal.ts  — every read goes through here, nothing queries the db directly
import 'server-only'
import { cache } from 'react'
import { cookies } from 'next/headers'

export const getSession = cache(async () => {
  const token = (await cookies()).get('session')?.value
  if (!token) return null
  return verifySession(token)          // signature + expiry, hits your store
})

export async function getInvoice(id: string) {
  const session = await getSession()
  if (!session) throw new Error('unauthenticated')

  const invoice = await db.invoice.findUnique({ where: { id } })
  // the check that matters: not "are you logged in" but "is this yours"
  if (!invoice || invoice.orgId !== session.orgId) throw new Error('not found')
  return invoice
}

The import 'server-only' line is doing real work. It makes the build fail if this module is ever pulled into a client component, which is the mistake that turns a data access layer into a bundle full of database code. And cache() means the session is verified once per request no matter how many components ask for it.

A check written here survives being reached through a page, a route handler, a Server Action, or whatever the framework adds next year. A check written in middleware survives none of those independently — it only survives the routes whose paths you remembered to put in the matcher.

Flow diagram of a Next.js request. An untrusted request reaches middleware.ts at the edge, which is marked as routing rather than a control: it has no database access, routing is not resolved, and CVE-2025-29927 skipped it entirely with one header. Middleware fans out to three front doors — a page or Server Component, a route handler, and a Server Action — which all converge on a data access layer marked import server-only, the only choke point that sees the session and the row together, and then on the database.
Three front doors, one choke point. A check at the data access layer covers a page, a route handler and a Server Action at once; a check in middleware covers only the paths you put in the matcher.

Server Components leak by omission, not by mistake

This is the one that has cost real companies real data, and it looks like nothing.

When a Server Component passes props to a Client Component, those props are serialised into the RSC payload — the flight data streamed to the browser alongside the HTML. That payload is not rendered, so it is not in the DOM and not in your screenshots. It is very much in view-source.

tsx
// Looks harmless. Ships the whole row.
export default async function Page({ params }) {
  const user = await db.user.findUnique({ where: { id: params.id } })
  return <ProfileCard user={user} />      // client component
}

If that row carries passwordHash, stripeCustomerId, internalNotes or an email the profile does not display, every one of them is now in the response body. Nothing in TypeScript complains, because the type is correct — the object really does have those fields.

The fix is a data transfer object at the boundary. Decide what the UI needs and pass only that:

tsx
const user = await db.user.findUnique({ where: { id: params.id } })
return <ProfileCard user={{ name: user.name, avatarUrl: user.avatarUrl }} />

Doing that by hand on every page is how it gets forgotten on page forty. React ships an experimental taint API that turns the omission into a build-time error, and Next.js exposes it behind a flag:

ts
// next.config.ts
export default { experimental: { taint: true } }
ts
import { experimental_taintObjectReference as taintObject } from 'react'

const user = await db.user.findUnique({ where: { id } })
taintObject('Do not pass the whole user row to the client', user)
return user

Now passing user across the boundary throws instead of shipping. Taint is a safety net rather than a design — it catches the object, not the individual field you copied out of it — but as a net under a data access layer it is worth the flag.

Server Actions are public endpoints with friendly syntax

A function marked 'use server' is compiled into a POST endpoint with a stable ID. The fact that your UI only calls it from a page that renders behind an admin check is irrelevant. Anyone who has ever loaded the page has the ID, and can call it with any arguments they like.

ts
'use server'

export async function deleteProject(projectId: string) {
  // Without these two lines this is a public delete-anything endpoint.
  const session = await getSession()
  if (!session) throw new Error('unauthenticated')

  const project = await db.project.findUnique({ where: { id: projectId } })
  if (project?.ownerId !== session.userId) throw new Error('not found')

  await db.project.delete({ where: { id: projectId } })
}

Next.js encrypts action IDs, which stops them being trivially enumerated from the bundle. That is obfuscation, and it is worth having, but it is not the authorisation check. Treat every action the way you would treat a route handler someone found in your OpenAPI spec: authenticate, authorise against the specific record, then validate the arguments with a schema. The arguments arrive over the network and are exactly as trustworthy as anything else that does.

The CSP decision that costs you static rendering

Every guide tells you to use a nonce-based Content Security Policy, and a nonce-based CSP is genuinely the strong option. The part they leave out is the price.

A nonce must be unique per response, so a page that embeds one cannot be rendered ahead of time. The moment your middleware generates a nonce and your layout reads it, every route that layout covers becomes dynamic. Static generation, the full-route cache and ISR all stop applying. On a marketing site or a documentation set, that is the difference between serving from cache and running the renderer on every request.

ts
// middleware.ts — strong policy, and every page it covers is now dynamic
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const csp = [
  `script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'`,
  "object-src 'none'",
  "base-uri 'self'",
  "frame-ancestors 'none'",
].join('; ')

The 'unsafe-inline' and https: in there are not a mistake. Browsers that understand 'strict-dynamic' ignore both; older ones fall back to them. That is the documented pattern, and it is why a CSP that looks careless can be the careful one.

The practical arrangement for most sites is to split the difference. Serve a nonce CSP on the authenticated routes, which are dynamic anyway because they read a session. Serve a static, hash-free policy on the public pages and keep their caching:

ts
// next.config.ts — applies to the routes that stay static
async headers() {
  return [{
    source: '/:path*',
    headers: [
      { key: 'Content-Security-Policy', value:
        "default-src 'self'; script-src 'self'; object-src 'none'; " +
        "base-uri 'self'; frame-ancestors 'none'" },
      { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
      { key: 'X-Content-Type-Options', value: 'nosniff' },
      { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
    ],
  }]
}

Ship it as Content-Security-Policy-Report-Only first. A CSP that breaks your analytics on a Friday teaches the team to remove CSPs.

Environment variables are inlined, not read

NEXT_PUBLIC_ variables are not looked up at runtime. They are substituted into the bundle as string literals at build time, which has two consequences people discover late.

Rotating one requires a rebuild, not a restart — changing it in your host's environment panel and redeploying the same image changes nothing. And because the substitution is textual, a public variable referenced in server code is still inlined there; the prefix decides exposure, not the file it appears in.

The audit is one command, and it is worth running against a built artefact rather than your source:

bash
grep -roE "NEXT_PUBLIC_[A-Z0-9_]+" .next/static | sort -u

Anything in that list is public forever, including in every copy of the bundle a user has already downloaded. If a secret appears, rotating it is the fix; removing the prefix is only the second step.

One more on this: if you build in Docker, check that .env is in .dockerignore. A COPY . . before the build puts your server-side secrets in an image layer, and layers are readable even when a later step deletes the file.

What is worth your time, in order

  • Move authorisation to a data access layer. One module, import 'server-only' at the top, every query behind a function that checks ownership. This is the change that makes the rest of the list optional rather than load-bearing.
  • Audit what crosses to client components. Search for props that are whole database rows. Turn on experimental.taint so the next one fails the build.
  • Put an auth check as the first statement of every Server Action. Then validate the arguments with Zod or equivalent.
  • Add the static security headers. HSTS, nosniff, referrer policy, frame-ancestors. Five minutes, no trade-offs, real value.
  • Decide on CSP deliberately. Nonces on authenticated routes, a static policy on cacheable ones, report-only until the reports are quiet.
  • Keep the framework patched. CVE-2025-29927 was a one-header bypass of an entire application's access control, and it was fixed in a patch release.

Notably absent: removing X-Powered-By. It is one line — poweredByHeader: false — and you may as well, but an attacker who cannot tell a Next.js application from its markup, its /_next/static paths and its RSC payload is not the attacker you are defending against. Spend the attention on the boundary instead.

Sources and scope

CVE-2025-29927 details, the affected version ranges and the patched releases are from the published advisory and Next.js's own release notes. The taint API is experimental in React and gated behind a Next.js flag, so check its status against the version you are on. Configuration examples are written against the App Router on Next.js 15; several of them differ under the Pages Router, and cookies() became async in 15, which is the change most likely to break a snippet copied from an older guide.

We have not tested these configurations against a published application and are not reporting findings from one. The failure modes described are drawn from the framework's documented behaviour — the RSC payload really does carry your props, and a Server Action really is an endpoint.

nextjsweb-securitycspserver-componentsserver-actionsauthorisationapp-router

Arslan ud Din Shafiq

Founder and lead editor of LearnCybers. Full-stack engineer with expertise in Linux systems, cybersecurity, cloud infrastructure and web development. Writing about practical technology since 2019.

Related reading

Newsletter

Get smarter about security

Practical guides, tooling notes and the developments actually worth your attention — delivered when there is something worth saying.

No spam. Unsubscribe in one click.