Next.js Security Best Practices: Server Actions, Auth, Headers & Hardening Guide

SCRs Team
April 3, 2026
Updated May 8, 2026
16 min read
517 words
Share

Next.js Security Best Practices for Server Actions, Middleware, and the App Router

If you are looking for Next.js security best practices, start with the parts of the framework that changed the trust boundary: Server Actions, middleware, Route Handlers, Server Components, and environment variable exposure. In Next.js 15 and 16, these features make it easier to ship fast, but also easier to expose server-side logic if authentication, authorization, and validation are not explicit.

This guide focuses on the highest-impact Next.js security controls for production apps: hardening Server Actions, protecting App Router endpoints, preventing middleware bypasses, locking down security headers, and avoiding common secrets leaks.

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);
}

Next.js Security FAQ

What are the most important Next.js security best practices?

The most important controls are authentication and authorization on every Server Action and Route Handler, strict input validation, rate limiting, safe handling of NEXT_PUBLIC_ variables, correct middleware matching, and production security headers such as CSP and HSTS.

Are Server Actions secure by default?

No. Server Actions are powerful, but they do not automatically inherit the protections many teams expect from traditional API routes. You still need explicit auth checks, input validation, authorization logic, and abuse controls.

How do I secure a Next.js App Router application?

Treat the App Router like a mixed trust boundary. Review which logic runs on the server, which values are exposed to the client, which routes are protected by middleware, and whether every state-changing action enforces authorization server-side.

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)
Editorial standards

Published by SecureCodeReviews

This article is part of our original AI security and cybersecurity content library. We show publish and update dates, keep company and policy pages public, and update important guidance when material changes affect readers.

Named author: SCRs Team
Published: Apr 3, 2026
Updated: May 8, 2026

Questions or corrections?

Review our editorial standards, learn more about the company, or contact us if a page needs clarification.

Secure Code Review

Want an expert review before this issue reaches production?

We combine manual code review with AppSec tooling to find vulnerabilities, logic flaws, and insecure patterns before release or audit deadlines.

Manual secure code review for real exploitable issues
Remediation guidance with clear engineering next steps
Useful for launch reviews, client audits, and security hardening

Talk to SecureCodeReviews

Get a scoped review path fast

Manual review
Actionable fixes
Fast turnaround
Security-focused

Advertisement