All posts

Next.js security

Next.js security checklist

Next.js has specific footguns AI coding tools hit every time. Here are the 8 most common, what each one actually does, and the fix.

By Prbl Security Team

Most Next.js security mistakes are not framework bugs. They are patterns that feel reasonable during development — a NEXT_PUBLIC_ prefix to make a variable accessible, a session check on the client side where it is visible, a JWT read with decode() because it does not throw — and silently become critical vulnerabilities in production. AI coding tools produce all of them at high frequency because they optimize for code that runs, not code that is secure.

1. NEXT_PUBLIC_ on a server-only secret

Any environment variable prefixed with NEXT_PUBLIC_ is baked into the JavaScript bundle at build time. It ships to every browser that loads your app. Anyone can open DevTools and find it.

// These are NOT secrets anymore — they go to every user's browser
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_...
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJ...
NEXT_PUBLIC_OPENAI_API_KEY=sk-...

// Correct — server-only, never in the bundle
STRIPE_SECRET_KEY=sk_live_...
SUPABASE_SERVICE_ROLE_KEY=eyJ...
OPENAI_API_KEY=sk-...

The NEXT_PUBLIC_ prefix is for values that are genuinely meant to be public: your Supabase project URL, your Stripe publishable key (not secret key), your analytics write key. Never use it on anything that grants server-side access. See NEXT_PUBLIC_ env variable leak.

Scan your own app for issues like these

Paste your live URL. We check what your app serves publicly for exposed keys and misconfigurations. No account, no install.

2. Missing server-side auth check in API routes

A client-side auth check (redirecting unauthenticated users from a page) does not protect an API route. Anyone can skip the UI and call the route directly. Every API route that reads or writes user data needs its own server-side check.

// VULNERABLE: no server-side auth check
// app/api/user/data/route.ts
export async function GET() {
  const data = await db.query('SELECT * FROM user_data');
  return Response.json(data); // anyone can call this
}

// CORRECT: check session server-side first
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';

export async function GET(req: Request) {
  const session = await getServerSession(authOptions);
  if (!session) return new Response('Unauthorized', { status: 401 });

  const data = await db.query(
    'SELECT * FROM user_data WHERE user_id = $1',
    [session.user.id]
  );
  return Response.json(data);
}

3. JWT decode without verify

jwt.decode() reads the JWT payload with no signature check — anyone can forge a token. Middleware or route handlers that call decode() instead of verify() accept any token unconditionally. Full explanation: JWT decode vs verify.

// VULNERABLE
const payload = jwt.decode(token); // no secret, no signature check

// CORRECT
const payload = jwt.verify(token, process.env.JWT_SECRET!, {
  algorithms: ['HS256'],
});

4. Fallback secrets in environment variable lookups

AI tools frequently generate patterns like process.env.JWT_SECRET || 'dev-secret'. In production, if the variable is ever missing, the app silently uses a value that was committed to source code. Rotate and fix:

// DANGEROUS — if JWT_SECRET is unset, uses a public default
const secret = process.env.JWT_SECRET || 'dev-secret-change-me';

// CORRECT — fail loudly so a misconfigured deploy is immediately visible
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error('JWT_SECRET is required');

5. Server Component data passed to a Client Component without auth

In the App Router, data fetched in a Server Component and passed as props to a Client Component is serialized to the page response. If a Server Component fetches sensitive data without checking the session, that data ships to the browser even if it is never displayed.

// VULNERABLE — fetches all user records and passes to client
// app/admin/page.tsx (Server Component)
export default async function AdminPage() {
  const users = await db.users.findAll(); // no session check
  return <UserTable users={users} />;
}

// CORRECT
import { redirect } from 'next/navigation';

export default async function AdminPage() {
  const session = await getServerSession(authOptions);
  if (!session?.user?.isAdmin) redirect('/');

  const users = await db.users.findAll();
  return <UserTable users={users} />;
}

6. CORS wildcard on authenticated endpoints

Access-Control-Allow-Origin: * is sometimes added to silence CORS errors during development and never removed. On endpoints that read authenticated user data, this allows any site to make cross-origin requests. Scope CORS to specific origins. See CORS wildcard fix.

7. Missing ownership check (BOLA) in API routes

Checking that a user is authenticated is not the same as checking that they own the specific resource they are requesting. Every route that fetches a record by ID must scope the query to the requesting user:

// VULNERABLE: any logged-in user can read any invoice
export async function GET(req: Request, { params }: { params: { id: string } }) {
  const session = await getServerSession(authOptions);
  if (!session) return new Response('Unauthorized', { status: 401 });

  const invoice = await db.invoice.findById(params.id); // not scoped to session user
  return Response.json(invoice);
}

// CORRECT: scope the query to the session user
const invoice = await db.invoice.findFirst({
  where: { id: params.id, userId: session.user.id },
});
if (!invoice) return new Response('Not Found', { status: 404 });

8. Hardcoded secret in source or git history

The most direct exposure: an API key, database URL, or JWT secret written literally into a source file or committed at any point in git history. Check your history — not just the current branch — and rotate anything that appeared there before doing anything else. See what happens when you push an API key and how to remove a secret from git history.

Quick self-audit

  • Search your codebase for NEXT_PUBLIC_ — are any of those values secrets?
  • Check every app/api/ or pages/api/ route for a session check at the top of the handler
  • Search for jwt.decode( and jwtDecode( — replace with verify
  • Search for || ' and || " near env var reads — remove fallback defaults
  • Run Prbl’s free scanner against your repo to catch the rest automatically

Common questions

Is Next.js secure by default?

Next.js itself does not introduce security vulnerabilities, but its architecture has footguns that AI tools consistently fall into: NEXT_PUBLIC_ prefix on server-only secrets (which ships them to the browser), missing server-side auth checks in API routes, and client-side-only rendering of access-controlled content. The framework is sound; the patterns AI tools apply to it are not.

What does NEXT_PUBLIC_ actually do to a secret?

Any environment variable with the NEXT_PUBLIC_ prefix is embedded in the JavaScript bundle at build time and shipped to every visitor's browser. It appears in plain text in the compiled bundle and is accessible via window.__NEXT_DATA__ in older versions. A secret with NEXT_PUBLIC_ is not a secret — it is a public value. Remove the prefix from any variable that should not be visible to users.

How do I protect a Next.js API route?

Add a server-side session or JWT check at the top of every route handler. For App Router, read the session in the route handler before returning any data. For Pages Router, use getServerSideProps or a middleware wrapper. Client-side auth checks (useSession, localStorage reads) do not protect API routes — a caller can skip the UI entirely and hit the route directly.

Where should I store a JWT in a Next.js app?

HttpOnly cookies. A JWT in localStorage is accessible by any JavaScript on the page, which means any XSS vulnerability can steal it. HttpOnly cookies cannot be read by JavaScript. Set Secure and SameSite=Strict as well. Next-auth and most auth libraries default to HttpOnly cookies for this reason.

What is the safest way to handle secrets in Next.js?

Server-side environment variables with no NEXT_PUBLIC_ prefix. Read them in API routes, Server Components, or getServerSideProps only — never in client components. Validate at startup that required variables are present and throw if they are missing, rather than silently falling back to a default value.

Ready to check your own app?

Paste your live URL. We check what your app serves publicly for exposed keys and misconfigurations. No account, no install.

Or see a live example scan first.

Next.js Security Checklist: The 8 Mistakes AI Tools Ship Into Every App | Prbl