All posts

How to Secure Your Next.js App: A Complete Guide with Ship Safe

Next.js moves a lot of code across the client/server boundary, and most Next.js security bugs are boundary bugs: a value that was supposed to stay on the server, a check that runs somewhere an attacker can skip, or an endpoint you did not realise you had published.

This guide covers the seven mistakes that actually show up in production Next.js applications, with the vulnerable code and the fix for each. It is written for the App Router, with notes where the Pages Router differs.

Quick start

cd your-nextjs-app
npx ship-safe audit .

Ship Safe detects Next.js and adjusts its rules — it knows that NEXT_PUBLIC_ is a real boundary, that Server Actions are public endpoints, and that middleware.ts is not an authorization layer.

1. Secrets leaked through NEXT_PUBLIC_

The NEXT_PUBLIC_ prefix does not "expose the variable to the browser". It inlines the literal value into the JavaScript bundle at build time. The string ends up in a file served from your CDN, and it stays there in every deployed copy of that bundle.

# .env.local — wrong
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_51H...
NEXT_PUBLIC_DATABASE_URL=postgres://user:pass@host/db

Rotating the key later does not help with copies already downloaded. Treat any secret that has ever carried this prefix as compromised.

# .env.local — right
STRIPE_SECRET_KEY=sk_live_51H...              # server only
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_51H...  # designed to be public

Two rules that catch nearly all of these:

  • If the value would appear in a screenshot of your bundle and you would mind, it must not be NEXT_PUBLIC_.
  • Publishable, publishable-by-design keys are fine. Anything named secret, private, password, token, or service_role is not.

To check what you have already shipped:

npm run build
grep -r "sk_live\|service_role\|postgres://" .next/static/

Any hit is live in your users' browsers right now.

2. Middleware is not an authorization layer

This is the mistake that produced CVE-2025-29927, a critical authorization bypass affecting every Next.js version from 11 through 15.2.2. A crafted x-middleware-subrequest header caused Next.js to skip middleware execution entirely. Every application relying on middleware as its only auth gate was open.

// middleware.ts — the vulnerable pattern
export function middleware(request: NextRequest) {
  const token = request.cookies.get('session');
  if (!token) return NextResponse.redirect(new URL('/login', request.url));
  return NextResponse.next();
}

export const config = { matcher: ['/dashboard/:path*', '/api/:path*'] };
// app/dashboard/page.tsx — no check of its own
export default async function Dashboard() {
  const data = await db.sensitiveRecords.findMany();  // reachable if middleware is skipped
  return <Records data={data} />;
}

Patch to 15.2.3 or 14.2.25 or later. But patching is the smaller half of the lesson: middleware runs before your route and can be bypassed by any bug in that path. It is an optimisation — redirect early, avoid rendering — not a security boundary.

Put the real check where the data is read:

// app/dashboard/page.tsx — authorization at the point of access
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';

export default async function Dashboard() {
  const session = await auth();
  if (!session?.user) redirect('/login');

  const data = await db.sensitiveRecords.findMany({
    where: { userId: session.user.id },   // scoped, not just gated
  });
  return <Records data={data} />;
}

Note the where clause. A check that only asks "is this person logged in" still lets any logged-in user read every record. Authentication is not authorization.

3. Server Actions are public HTTP endpoints

A Server Action looks like a function call. It is not. Next.js assigns each action an ID and publishes a POST endpoint that invokes it. Anyone who can read your bundle can find the ID and call the action directly, with arguments of their choosing, in any order, at any time.

// app/actions.ts — vulnerable
'use server';

export async function deleteProject(projectId: string) {
  await db.project.delete({ where: { id: projectId } });
}

The UI only offers this button to a project owner. The endpoint offers it to everyone.

// app/actions.ts — validated and authorized
'use server';

import { z } from 'zod';
import { auth } from '@/lib/auth';

const DeleteProject = z.object({ projectId: z.string().uuid() });

export async function deleteProject(input: unknown) {
  const session = await auth();
  if (!session?.user) throw new Error('Unauthorized');

  const { projectId } = DeleteProject.parse(input);

  // Ownership is checked in the query, so a race cannot slip between
  // a separate check and the delete.
  const deleted = await db.project.deleteMany({
    where: { id: projectId, ownerId: session.user.id },
  });
  if (deleted.count === 0) throw new Error('Not found');
}

Every Server Action needs all three: authentication, input validation, and an ownership constraint expressed in the query itself.

4. Server Components leak whatever you pass down

Server Components can read anything the server can read. Whatever you pass to a Client Component is serialised into the page payload — visible in view-source, regardless of what you render.

// Leaks the full user record, including passwordHash and stripeCustomerId
export default async function Page() {
  const user = await db.user.findUnique({ where: { id } });
  return <Profile user={user} />;   // Profile is 'use client'
}

Select what you need:

export default async function Page() {
  const user = await db.user.findUnique({
    where: { id },
    select: { id: true, name: true, avatarUrl: true },
  });
  return <Profile user={user} />;
}

For objects that must never cross the boundary, Next.js can fail the build instead of trusting you to remember:

// lib/user.ts
import { experimental_taintObjectReference } from 'react';

export async function getUser(id: string) {
  const user = await db.user.findUnique({ where: { id } });
  experimental_taintObjectReference(
    'Do not pass the full user object to the client.',
    user,
  );
  return user;
}

5. Route Handlers without authentication or rate limiting

// app/api/users/route.ts — vulnerable
export async function GET() {
  return Response.json(await db.user.findMany());
}

Route Handlers are public the moment the file exists. No middleware, no implicit protection.

// app/api/users/route.ts — gated and bounded
import { auth } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit';

export async function GET(request: Request) {
  const session = await auth();
  if (!session?.user) return new Response('Unauthorized', { status: 401 });

  const { success } = await rateLimit(session.user.id);
  if (!success) return new Response('Too many requests', { status: 429 });

  const users = await db.user.findMany({
    where: { orgId: session.user.orgId },
    select: { id: true, name: true, email: true },
    take: 100,
  });
  return Response.json(users);
}

Rate limiting matters most on anything that sends email, resets a password, or calls a paid API. Those are the endpoints that cost you money when they are abused.

6. XSS through dangerouslySetInnerHTML

React escapes output by default. This prop turns that off.

<div dangerouslySetInnerHTML={{ __html: comment.body }} />

If comment.body came from a user, that is stored XSS — an attacker's script running with your users' sessions.

import DOMPurify from 'isomorphic-dompurify';

<div dangerouslySetInnerHTML={{
  __html: DOMPurify.sanitize(comment.body, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
    ALLOWED_ATTR: ['href'],
  }),
}} />

Sanitize on the way out, not only on the way in. Data gets into a database through migrations, imports, and admin tools that never touched your validation.

7. No Content-Security-Policy

Next.js sets no security headers by default. A CSP is the control that limits the damage when one of the above gets through.

// next.config.js — baseline headers
const securityHeaders = [
  { key: 'X-Frame-Options', value: 'DENY' },
  { key: 'X-Content-Type-Options', value: 'nosniff' },
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
  { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
];

module.exports = {
  async headers() {
    return [{ source: '/:path*', headers: securityHeaders }];
  },
};

A real CSP needs per-request nonces, because Next.js injects inline scripts:

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
  const csp = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'unsafe-inline'`,
    `img-src 'self' blob: data:`,
    `frame-ancestors 'none'`,
    `base-uri 'self'`,
  ].join('; ');

  const headers = new Headers(request.headers);
  headers.set('x-nonce', nonce);
  headers.set('content-security-policy', csp);

  const response = NextResponse.next({ request: { headers } });
  response.headers.set('content-security-policy', csp);
  return response;
}

Deploy it as content-security-policy-report-only first and watch the reports. A CSP that breaks checkout will be removed within a day, which leaves you with no CSP at all.

Supabase and Row Level Security

If you use Supabase with Next.js, two failures account for most incidents.

The `service_role` key in client-reachable code. It bypasses every RLS policy. It belongs only in Server Actions, Route Handlers, and Server Components — never in a 'use client' file, and never behind NEXT_PUBLIC_.

RLS enabled with no policy, or a policy that only checks authentication.

-- Anyone logged in can read every row
create policy "authenticated users can read"
  on projects for select
  using (auth.role() = 'authenticated');

-- Correct: a user reads their own rows
create policy "users read own projects"
  on projects for select
  using (auth.uid() = owner_id);

Ship Safe's SupabaseRLSAgent flags tables with RLS disabled, policies that check only auth.role(), and service_role usage reachable from the client bundle.

Wiring it into CI

Catching this in review is better than catching it in production, and catching it automatically is better than remembering.

name: Security Audit
on: [push, pull_request]

jobs:
  ship-safe:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: asamassekou10/ship-safe@v6
        with:
          path: .
          threshold: 70
          github-pr: true

threshold: 70 fails the build below a score of 70. Start permissive, tighten as you fix things — a gate that fails on day one gets disabled on day two.

The checklist

After running npx ship-safe audit ., verify:

  • No secrets behind NEXT_PUBLIC_, and none in .next/static/
  • Next.js patched to 15.2.3+ or 14.2.25+ (CVE-2025-29927)
  • Authorization at the point of data access, not only in middleware
  • Every Server Action: authenticated, validated, ownership in the query
  • Server Components select only the fields the client needs
  • Route Handlers check auth and rate limit anything expensive
  • dangerouslySetInnerHTML sanitized on output
  • Security headers set, CSP with nonces
  • Supabase RLS on, policies check auth.uid() not auth.role()
  • service_role unreachable from client code
  • Dependencies current, CI gate in place

FAQ

Is Next.js secure by default?

Partly. Next.js escapes React output, keeps non-prefixed environment variables server-side, and separates server and client code. It sets no security headers, applies no rate limiting, and does not authorize anything — Route Handlers and Server Actions are public the moment they exist. The framework secures the boundary; you secure what crosses it.

Can someone see my environment variables in Next.js?

Only those prefixed NEXT_PUBLIC_, and those are not merely visible — their values are compiled into the JavaScript bundle at build time. Everything else stays on the server. Verify with grep -r "your-secret" .next/static/ after a build; any match is already public.

Are Server Actions safe from being called directly?

No. Each Server Action is published as a POST endpoint with a generated ID that appears in the client bundle. An attacker can call it directly with arbitrary arguments, bypassing your UI entirely. Every action needs its own authentication, input validation, and ownership check.

Does Next.js middleware protect my routes?

Not reliably. CVE-2025-29927 allowed middleware to be skipped entirely with a crafted header, exposing every application that used it as the only auth gate. Treat middleware as an optimisation for early redirects, and put real authorization checks in the page, action, or handler that touches the data.

What is the fastest way to find these issues in an existing app?

Run npx ship-safe audit . in the project root. It scans for exposed secrets, unauthenticated routes and actions, missing headers, unsafe HTML rendering, and Supabase RLS problems, and reports them by severity. No account or API key is needed for a local scan.

Related reading

Ship fast. Ship safe.