Application Security
nextjs
react
server components
server actions
+2 more

Next.js Security: The Complete Hardening Guide for 2026

SCRs Team
April 3, 2026
16 min read
Share

Next.js Security Has Fundamentally Changed

Next.js 15 and 16 introduced Server Components, Server Actions, and the App Router — blurring the line between client and server code. This creates entirely new attack vectors that didn't exist in Pages Router applications.

FeatureSecurity ImpactRisk Level
Server ComponentsServer code accidentally exposed to clientHigh
Server ActionsDirect function calls bypass API middlewareCritical
Route HandlersMissing auth checks on API routesHigh
MiddlewareAuth bypass via matcher misconfigurationHigh
Environment VariablesClient/server variable confusionMedium

Server Actions: The Biggest Attack Surface

Server Actions let you call server-side functions directly from the client — without going through an API route. This means all your API middleware (auth, rate limiting, CORS) gets bypassed.

❌ Vulnerable Server Action

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

export async function deleteUser(userId: string) {
  // NO AUTH CHECK — anyone can call this!
  await db.user.delete({ where: { id: userId } });
  return { success: true };
}

An attacker can call this action directly via POST request:

curl -X POST https://yourapp.com/actions \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "Next-Action: <action-id>" \
  -d "userId=admin-user-id"

✅ Secure Server Action

'use server';

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

const DeleteUserSchema = z.object({
  userId: z.string().uuid(),
});

export async function deleteUser(rawData: unknown) {
  // 1. Authentication
  const session = await auth();
  if (!session?.user) throw new Error('Unauthorized');
  
  // 2. Input validation
  const { userId } = DeleteUserSchema.parse(rawData);
  
  // 3. Authorization — only admins or self-delete
  if (session.user.role !== 'admin' && session.user.id !== userId) {
    throw new Error('Forbidden');
  }
  
  // 4. Rate limiting
  await rateLimit(session.user.id, 'deleteUser', { max: 5, window: '1h' });
  
  await db.user.delete({ where: { id: userId } });
  revalidatePath('/users');
  return { success: true };
}

Every Server Action needs: Authentication → Input Validation → Authorization → Rate Limiting


Environment Variable Leaks

Next.js has a dangerous naming convention:

# .env.local

# ✅ Server-only (safe)
DATABASE_URL=postgres://user:pass@host/db
SECRET_KEY=my-secret-key

# ❌ EXPOSED TO CLIENT BROWSER
NEXT_PUBLIC_DATABASE_URL=postgres://user:pass@host/db  # NEVER DO THIS
NEXT_PUBLIC_SECRET_KEY=my-secret-key                    # NEVER DO THIS

# ✅ Safe public variables
NEXT_PUBLIC_APP_URL=https://myapp.com
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...

Rule: Only prefix with NEXT_PUBLIC_ if the value can be seen by anyone.

Checking for Leaked Variables

# Search your codebase for dangerous patterns
grep -r "NEXT_PUBLIC_.*SECRET\|NEXT_PUBLIC_.*PASSWORD\|NEXT_PUBLIC_.*DATABASE" .

Middleware Authentication Bypass

Next.js middleware runs on the Edge Runtime and is powerful for auth — but matcher patterns are tricky.

❌ Bypassable Middleware

// middleware.ts
export const config = {
  matcher: ['/dashboard', '/api/:path*'],
};

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

// ❌ These routes are NOT protected:
// /Dashboard (case-sensitive bypass)
// /dashboard/ (trailing slash)
// /api (exact match might not catch this)

✅ Secure Middleware

export const config = {
  matcher: [
    // Match all routes except static files and public assets
    '/((?!_next/static|_next/image|favicon.ico|public/).*)',
  ],
};

const publicPaths = ['/login', '/register', '/forgot-password', '/', '/about'];

export function middleware(request: NextRequest) {
  const path = request.nextUrl.pathname.toLowerCase();
  
  // Skip public paths
  if (publicPaths.some(p => path === p || path === p + '/')) {
    return NextResponse.next();
  }
  
  const token = request.cookies.get('session');
  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
  
  // Verify token is valid (basic check — full validation in route)
  try {
    const payload = verifyToken(token.value);
    const headers = new Headers(request.headers);
    headers.set('x-user-id', payload.userId);
    return NextResponse.next({ headers });
  } catch {
    return NextResponse.redirect(new URL('/login', request.url));
  }
}

Security Headers Configuration

// next.config.js
const securityHeaders = [
  { key: 'X-Content-Type-Options', value: 'nosniff' },
  { key: 'X-Frame-Options', value: 'DENY' },
  { key: 'X-XSS-Protection', value: '1; mode=block' },
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
  {
    key: 'Content-Security-Policy',
    value: [
      "default-src 'self'",
      "script-src 'self' 'unsafe-eval' 'unsafe-inline'", // Tighten for production
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' data: blob: https:",
      "font-src 'self'",
      "connect-src 'self' https://api.yourapp.com",
      "frame-ancestors 'none'",
    ].join('; '),
  },
  {
    key: 'Strict-Transport-Security',
    value: 'max-age=63072000; includeSubDomains; preload',
  },
];

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

Route Handler Security Checklist

// app/api/users/[id]/route.ts
import { auth } from '@/lib/auth';
import { z } from 'zod';
import { NextResponse } from 'next/server';
import { rateLimit } from '@/lib/rate-limit';

const ParamsSchema = z.object({ id: z.string().uuid() });

export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  // ✅ Rate limiting
  const ip = request.headers.get('x-forwarded-for') ?? 'unknown';
  const { success } = await rateLimit(ip, 'api', { max: 100, window: '1m' });
  if (!success) return NextResponse.json({ error: 'Too many requests' }, { status: 429 });

  // ✅ Authentication
  const session = await auth();
  if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  // ✅ Input validation
  const parsed = ParamsSchema.safeParse(params);
  if (!parsed.success) return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });

  // ✅ Authorization
  if (session.user.role !== 'admin' && session.user.id !== parsed.data.id) {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }

  const user = await db.user.findUnique({ where: { id: parsed.data.id } });
  if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 });

  // ✅ Don't return sensitive fields
  const { passwordHash, ...safeUser } = user;
  return NextResponse.json(safeUser);
}

Quick Reference: Next.js Security Checklist

  • Every Server Action has auth + input validation + authorization
  • No secrets in NEXT_PUBLIC_ environment variables
  • Middleware covers all protected routes (test edge cases)
  • Security headers configured in next.config.js
  • CSRF protection on state-changing operations
  • Rate limiting on all API routes and Server Actions
  • dangerouslySetInnerHTML only used with DOMPurify sanitization
  • File uploads validated (type, size, content) server-side
  • Error responses don't leak stack traces or internal details
  • Dependencies audited (npm audit, Snyk, or ShieldX)

Advertisement