How do I secure a Next.js app?

Direct answer

Most Next.js security problems come from the server/client boundary. Anything prefixed `NEXT_PUBLIC_` ships to the browser, server components can leak data through props, server actions are public HTTP endpoints whether or not you treat them as such, and middleware is the wrong place to put your only authorisation check.

Muhammad HasanUpdated

Next.js blurs the line between server and client deliberately, which is what makes it pleasant to write and where most of its security problems come from.

Environment variables

Any variable prefixed NEXT_PUBLIC_ is inlined into the client bundle at build time. It is public. Not obscured, not hard to find. A string in JavaScript anyone can read.

DATABASE_URL=postgres://...              # server only, fine
NEXT_PUBLIC_API_KEY=sk_live_...          # in the browser, not fine

Check your deployed bundle rather than trusting the config. View source and search for sk_, secret and key. Hardcoded credentials in client-side code were one of the most common findings across the 24 AI-generated applications we scanned, usually because something needed to work in a preview.

Server components leak through props

A server component can query the database directly, which is the point. The trap is passing the result to a client component.

// Server component
const user = await db.users.findById(id);   // full row
return <Profile user={user} />;             // serialised to the client

Everything in user is now in the page payload, password hash included. Select the fields you need, or narrow before you pass.

Server actions are public endpoints

A server action compiles to an HTTP endpoint with a generated identifier. Anyone who finds the identifier can call it directly, with whatever arguments they like, the form you built is not a constraint.

So every server action needs its own authentication and authorisation checks, exactly like an API route:

'use server';
export async function deletePost(id) {
  const session = await auth();
  if (!session) throw new Error('Unauthorised');

  const post = await db.posts.findOne({ id, userId: session.user.id });
  if (!post) throw new Error('Not found');

  await db.posts.delete({ id });
}

The ownership check matters as much as the session check. A logged-in user calling the action directly with someone else's post ID is the same broken-authorisation pattern that shows up everywhere else.

Middleware is not your auth layer

Middleware is a reasonable place for redirects and a poor place for your only authorisation check. Its matcher config is easy to get subtly wrong, and the framework has had a bypass at exactly this layer. CVE-2025-29927 let an attacker skip middleware entirely by sending an x-middleware-subrequest header, which Next.js trusted without checking where it came from. It affected every version after 11.1.4 and was patched in 12.3.5, 13.5.9, 14.2.25 and 15.2.3.

The patch closed that particular hole. The lesson generalises: if middleware is the only thing standing between an anonymous request and your data, a framework bug becomes a full authorisation bypass.

Treat it as a convenience layer. Put the real check in the route handler, server action or data access layer, where it runs regardless of how the request arrived.

Everything else

  • Set security headers in next.config.js. CSP, HSTS, X-Frame-Options

  • Route handlers need the same five layers as any API endpoint

  • Keep Next.js itself updated; framework-level auth bypasses have been found and patched more than once

Where this gets hard

Each of these is simple in isolation. The difficulty is that a Next.js app of any size has a lot of server actions, route handlers and data access points, and every one needs the ownership check. Getting it right ninety-nine times and missing once is the same outcome as never doing it.

That's a coverage problem rather than a knowledge problem, and it's why it needs analysis that follows data across the codebase rather than a checklist.

Go deeper

Security for AI-generated code

Related answers

See what your own repository returns

Connect a repo and run a scan. No credit card, no pipeline changes.

Get started for free