Blog
blog4 min

A Next.js security checklist that fits on one page

Server Actions, the renamed proxy file, and public environment variables each have a default that surprises people. What to verify before you launch.

The Next.js wordmark beside a browser window and a five item security checklist covering Server Actions, proxy, public environment variables, input validation and staying patched

A Next.js security checklist is mostly a list of defaults, because almost none of the framework's sharp edges are bugs. They are documented behaviours that read as safer than they are. Four of them account for most of what goes wrong, and one of them changed name in Next.js 16, which is worth knowing before you copy advice written for an older version.

Server Actions are public POST endpoints

This is the most important item on the list. A Server Action is reachable by direct POST request, whether or not the component that calls it ever renders for a given user. Next.js generates encrypted, non-deterministic action IDs and strips unused actions from the bundle at build time, which raises the bar. It does not change the model. The framework's own guidance is explicit that you should still treat actions as reachable and verify inside each one.

The failure mode that catches people is assuming a page-level check covers the actions defined on that page. It does not:

export default async function AdminPage() {
  const session = await auth()
  if (!session?.user?.isAdmin) redirect('/login')   // controls the UI only

  return (
    <form
      action={async () => {
        'use server'
        const session = await auth()                 // and this controls the action
        if (!session?.user?.isAdmin) throw new Error('Unauthorized')
        await db.record.deleteMany()
      }}
    >
      <button>Delete records</button>
    </form>
  )
}

The redirect decides what gets rendered. The action is a separate entry point and has to authorise the caller itself. Then check authorisation as well as authentication: being logged in is not permission to act on a specific row.

middleware is now proxy, and it was never an auth boundary

Next.js 16 deprecates the middleware filename and renames it to proxy, to make the network-and-routing role clearer. The migration is mechanical:

mv middleware.ts proxy.ts

The named export renames too, from middleware to proxy, and config flags follow: skipMiddlewareUrlNormalize becomes skipProxyUrlNormalize.

The security point survives the rename. The Next.js documentation warns that a matcher change, or a refactor that moves a Server Function to a different route, can silently remove proxy coverage, and that you should verify authentication and authorisation inside each function rather than relying on the proxy alone.

If you want a reason to take that seriously, CVE-2025-29927 is it. Next.js trusted an internal x-middleware-subrequest header to prevent middleware loops. Sending that header yourself caused the middleware to be skipped entirely, bypassing every check it contained. It affected versions before 12.3.5, 13.5.9, 14.2.25 and 15.2.3. An authorisation layer that a single request header can switch off was never a boundary, and the fix in your own code is the same either way: check again where the work happens.

Anything prefixed NEXT_PUBLIC_ is published

The prefix is not a hint about intent. It is an instruction to inline the value into the client bundle at build time, and Next.js replaces unprefixed variables with an empty string on the client so they cannot leak by accident.

The practical consequence is that adding the prefix to make a value "work in the browser" is the moment it stops being a secret. If you have done that with an API key, see your API key is in your JavaScript bundle for how to find out what already shipped.

Treat every dynamic value as attacker-controlled

Route params, searchParams, headers and form data all arrive from the client and can all be edited. Validate them at the point of use, not at the point you expected them to come from.

For defence in depth on the data side, Next.js can enable React's taint APIs through the experimental.taint option, which lets you mark an object or a specific value as something that must never cross into a Client Component. It is a good backstop for a whole-user record that should never be serialised to the browser, and a poor substitute for choosing what to select in the first place.

The short version

  1. Auth and authorisation inside every Server Action, not just on the page.
  2. Rename middleware.ts to proxy.ts, and do not treat it as your auth layer.
  3. Audit every NEXT_PUBLIC_ variable and assume it is public, because it is.
  4. Validate params, headers and form data where you use them.
  5. Pin a patched Next.js version and keep it patched.

If your app also talks to Supabase from the browser, the database is doing the rest of the work, and four Supabase RLS mistakes that expose every row is the other half of this checklist. For the checks to run against a finished app rather than its source, start with the security checklist nobody runs on a vibecoded app. For the framework-agnostic version, the OWASP Insecure Direct Object Reference prevention cheat sheet is the reference worth reading once properly.

GhostRecon exercises these paths against a running app, including calling actions directly and replaying requests with the wrong session, and verifies what it finds before telling you about it.

New here?

GhostRecon is a free AI pentester. It attacks your live app from the outside in, with no code and no credentials, the same way a real adversary would, then verifies every finding before it reports it.

Download for Mac

Keep reading