Web Security

Next.js Security Best Practices: Headers, Auth, and API Routes

Secure your Next.js application from the ground up — covering security headers, API route protection, server actions, environment variable handling, rate limiting, and Content Security Policy configuration.

September 2, 20257 min readShipSafer Team

Next.js combines a React frontend with a Node.js server in a single framework. That integration is powerful, but it also means you need to think about security at multiple layers simultaneously: browser-facing headers, server-side API routes, server actions, and the boundary between what runs on the client versus the server.

This guide walks through the most important security controls for a production Next.js application.

1. Security Headers in next.config.ts

Next.js makes it straightforward to attach HTTP security headers to every response through the headers() configuration:

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          // Prevent clickjacking
          { key: 'X-Frame-Options', value: 'DENY' },

          // Prevent MIME sniffing
          { key: 'X-Content-Type-Options', value: 'nosniff' },

          // Restrict referrer information
          { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },

          // Restrict browser features
          {
            key: 'Permissions-Policy',
            value: 'camera=(), microphone=(), geolocation=(), payment=()',
          },

          // Force HTTPS (set via your CDN for more coverage)
          {
            key: 'Strict-Transport-Security',
            value: 'max-age=63072000; includeSubDomains; preload',
          },

          // Content Security Policy
          {
            key: 'Content-Security-Policy',
            value: [
              "default-src 'self'",
              "script-src 'self' 'unsafe-eval'", // remove unsafe-eval if possible
              "style-src 'self' 'unsafe-inline'",
              "img-src 'self' data: https:",
              "font-src 'self'",
              "connect-src 'self' https://api.yourdomain.com",
              "object-src 'none'",
              "frame-ancestors 'none'",
              "base-uri 'self'",
              "form-action 'self'",
            ].join('; '),
          },
        ],
      },
    ];
  },
};

export default nextConfig;

Verify your headers after deployment using securityheaders.com or your ShipSafer security scan.

2. Protecting API Routes with Auth Middleware

Every API route under app/api/ is publicly reachable by default. Protect them with authentication middleware.

Middleware Approach

Next.js middleware runs before any route handler, making it ideal for auth checks:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyToken } from '@/lib/auth';

const PROTECTED_PATTERNS = ['/api/admin', '/api/user', '/dashboard'];

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  const isProtected = PROTECTED_PATTERNS.some(p => pathname.startsWith(p));
  if (!isProtected) return NextResponse.next();

  const token = request.cookies.get('authToken')?.value;
  if (!token) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const payload = verifyToken(token);
  if (!payload) {
    return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
  }

  // Pass user info to the route handler via headers
  const response = NextResponse.next();
  response.headers.set('x-user-id', payload.userId);
  response.headers.set('x-user-role', payload.role);
  return response;
}

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

Per-Route Auth Check

For granular control, also check auth inside the route handler itself:

// app/api/admin/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getAuthenticatedUser } from '@/lib/auth-middleware';

export async function GET(request: NextRequest) {
  const user = await getAuthenticatedUser();
  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  if (user.role !== 'admin') {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }

  // admin-only logic
}

3. Server Actions Security

Server actions are powerful — they run server-side code directly called from client components. This convenience makes it easy to forget that they are effectively API endpoints.

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

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

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

export async function deleteUser(input: unknown) {
  // 1. Authenticate
  const caller = await getAuthenticatedUser();
  if (!caller) return { success: false, error: 'Unauthorized' };

  // 2. Authorize
  if (caller.role !== 'admin') return { success: false, error: 'Forbidden' };

  // 3. Validate input
  const result = DeleteUserSchema.safeParse(input);
  if (!result.success) return { success: false, error: 'Invalid input' };

  // 4. Perform operation
  await User.deleteOne({ userId: result.data.userId });

  return { success: true };
}

Common mistakes with server actions:

  • Forgetting to authenticate — the action is callable from any browser console.
  • Trusting the data shape — always validate with Zod before use.
  • Returning full error objects — return sanitized messages only.

4. Environment Variable Exposure

Next.js has two categories of environment variables:

PrefixAvailable inRisk
NEXT_PUBLIC_Browser + ServerBundle-exposed
(no prefix)Server onlySafe for secrets
# .env.local
DATABASE_URL=mongodb+srv://...           # Safe — server only
JWT_SECRET=supersecretkey                # Safe — server only
STRIPE_SECRET_KEY=sk_live_...           # Safe — server only
NEXT_PUBLIC_STRIPE_PUBLISHABLE=pk_live_ # Fine — intended for browser
NEXT_PUBLIC_DATABASE_URL=...            # DANGEROUS — never do this

After every deployment, check whether secrets leaked:

# Search Next.js build artifacts
grep -r "sk_live" .next/static/chunks/
grep -r "mongodb+srv" .next/static/

server-only Imports

Use the server-only package to hard-fail at build time if a server module is imported client-side:

// lib/db.ts
import 'server-only';

import mongoose from 'mongoose';
export async function connectDB() { /* ... */ }

5. Content Security Policy with Nonces

A static CSP breaks with Next.js's inline script injection. The recommended approach is nonce-based CSP, where a random value authorizes each inline script for that specific request.

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

export function middleware(request: NextRequest) {
  const nonce = crypto.randomBytes(16).toString('base64');
  const csp = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}'`,
    "style-src 'self' 'unsafe-inline'",
    "img-src 'self' data: https:",
    "object-src 'none'",
    "frame-ancestors 'none'",
  ].join('; ');

  const response = NextResponse.next();
  response.headers.set('Content-Security-Policy', csp);
  response.headers.set('x-nonce', nonce); // Pass to layout component
  return response;
}
// app/layout.tsx
import { headers } from 'next/headers';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const nonce = (await headers()).get('x-nonce') ?? '';

  return (
    <html>
      <head>
        <script nonce={nonce} src="/scripts/analytics.js" />
      </head>
      <body>{children}</body>
    </html>
  );
}

6. Rate Limiting API Routes

Without rate limiting, your API routes are vulnerable to brute-force attacks, credential stuffing, and abuse.

npm install @upstash/ratelimit @upstash/redis
// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

export const authLimiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '15 m'),
  prefix: 'ratelimit:auth',
});

export const apiLimiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(100, '1 m'),
  prefix: 'ratelimit:api',
});
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { authLimiter } from '@/lib/rate-limit';

export async function POST(request: NextRequest) {
  const ip = request.headers.get('x-forwarded-for') ?? 'unknown';
  const { success, limit, remaining, reset } = await authLimiter.limit(ip);

  if (!success) {
    return NextResponse.json(
      { error: 'Too many requests. Please try again later.' },
      {
        status: 429,
        headers: {
          'RateLimit-Limit': limit.toString(),
          'RateLimit-Remaining': remaining.toString(),
          'RateLimit-Reset': reset.toString(),
          'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(),
        },
      }
    );
  }

  // proceed with login
}

7. Disable Unnecessary Next.js Features

// next.config.ts
const nextConfig: NextConfig = {
  // Prevent exposing Next.js version
  poweredByHeader: false,

  // Restrict image domains to prevent SSRF via next/image
  images: {
    remotePatterns: [
      { protocol: 'https', hostname: 'cdn.yourdomain.com' },
      // Do NOT add '*' — only allowlist domains you control
    ],
  },

  // Disable source maps in production (prevents source code exposure)
  productionBrowserSourceMaps: false,
};

8. CORS for API Routes

By default, Next.js API routes accept requests from any origin. Set explicit CORS headers:

// app/api/public/route.ts
import { NextRequest, NextResponse } from 'next/server';

const ALLOWED_ORIGINS = ['https://yourdomain.com', 'https://app.yourdomain.com'];

export async function GET(request: NextRequest) {
  const origin = request.headers.get('origin');
  const allowedOrigin = origin && ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0];

  return NextResponse.json({ data: 'public data' }, {
    headers: {
      'Access-Control-Allow-Origin': allowedOrigin,
      'Access-Control-Allow-Methods': 'GET',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      'Vary': 'Origin',
    },
  });
}

Summary Checklist

  • Security headers configured in next.config.ts (CSP, HSTS, X-Frame-Options, etc.)
  • API routes protected by authentication middleware
  • Server actions authenticate and validate all inputs
  • No secrets in NEXT_PUBLIC_ env vars
  • server-only imports for modules with secrets
  • Rate limiting on auth and sensitive endpoints
  • poweredByHeader: false and productionBrowserSourceMaps: false
  • Image domains allowlisted (no wildcard)
  • CORS explicitly configured for API routes

Next.js security is a layered problem. No single control is sufficient — combine headers, auth middleware, input validation, and dependency scanning to build a defense-in-depth posture for your application.

nextjs
security headers
api security
server actions
rate limiting

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.