SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Mar 16, 2026·Security·SecureStartKit Team

The Next.js Security Hardening Checklist: 12 Steps to Ship a Secure App

A production security checklist for Next.js apps. Covers HTTP headers, CSP, environment variables, Server Actions, RLS, webhook verification, and more.

Summarize with AI

On this page

  • Table of Contents
  • 1. Set HTTP Security Headers
  • 2. Implement Content Security Policy
  • 3. Separate Server and Client Environment Variables
  • 4. Validate All Input with Zod
  • 5. Protect Server Actions with Rate Limiting
  • 6. Use Backend-Only Data Access
  • 7. Enable Row Level Security with Default Deny
  • 8. Verify Webhook Signatures
  • 9. Add Authentication Middleware
  • 10. Authorize Every Server Action
  • 11. Keep Dependencies Updated
  • 12. Scan for Leaked Secrets
  • The Checklist

On this page

  • Table of Contents
  • 1. Set HTTP Security Headers
  • 2. Implement Content Security Policy
  • 3. Separate Server and Client Environment Variables
  • 4. Validate All Input with Zod
  • 5. Protect Server Actions with Rate Limiting
  • 6. Use Backend-Only Data Access
  • 7. Enable Row Level Security with Default Deny
  • 8. Verify Webhook Signatures
  • 9. Add Authentication Middleware
  • 10. Authorize Every Server Action
  • 11. Keep Dependencies Updated
  • 12. Scan for Leaked Secrets
  • The Checklist

In December 2025, a CVSS 10.0 vulnerability was disclosed in the React Server Components protocol. A standard Next.js App Router application, created with create-next-app and deployed in its default configuration, was vulnerable to unauthenticated remote code execution [3]. One month later, CVE-2026-23864 revealed a denial-of-service vector that causes memory exhaustion through a single crafted request [4].

These are not edge cases. They are the reality of shipping a Next.js application in 2026. The OWASP Top 10:2025 edition, based on analysis of over 175,000 vulnerabilities across 515,000 applications, found that 3.73% of tested apps had broken access control and 3% had security misconfigurations [1]. The average breach takes 200 days to discover.

This checklist covers 12 concrete steps to harden your Next.js application before it reaches production. Each step includes real code from a production codebase, not hypothetical examples.

Table of Contents

  • 1. Set HTTP Security Headers
  • 2. Implement Content Security Policy
  • 3. Separate Server and Client Environment Variables
  • 4. Validate All Input with Zod
  • 5. Protect Server Actions with Rate Limiting
  • 6. Use Backend-Only Data Access
  • 7. Enable Row Level Security with Default Deny
  • 8. Verify Webhook Signatures
  • 9. Add Authentication Middleware
  • 10. Authorize Every Server Action
  • 11. Keep Dependencies Updated
  • 12. Scan for Leaked Secrets
  • The Checklist

1. Set HTTP Security Headers

HTTP security headers instruct the browser to enforce protections that your application code cannot guarantee on its own. Without them, your app is vulnerable to clickjacking, MIME type confusion, protocol downgrade attacks, and cross-site scripting.

Configure these headers in next.config.ts so they apply to every route automatically:

// next.config.ts
const securityHeaders = [
  { key: 'X-Frame-Options', value: 'DENY' },
  { key: 'X-Content-Type-Options', value: 'nosniff' },
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
  { key: 'X-DNS-Prefetch-Control', value: 'on' },
  {
    key: 'Strict-Transport-Security',
    value: 'max-age=63072000; includeSubDomains; preload',
  },
]

const nextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: securityHeaders,
      },
    ]
  },
}

Here is what each header does:

  • X-Frame-Options: DENY prevents your site from being embedded in iframes, blocking clickjacking attacks where an attacker overlays invisible buttons on your UI.
  • X-Content-Type-Options: nosniff stops browsers from guessing the MIME type of a response. Without this, a browser might execute a JSON response as JavaScript.
  • Referrer-Policy controls how much URL information is sent to external sites. strict-origin-when-cross-origin sends the full path for same-origin requests but only the domain for cross-origin requests.
  • Strict-Transport-Security (HSTS) forces all connections over HTTPS for two years. The preload directive lets you submit your domain to browser preload lists, so even the first visit uses HTTPS.

These headers cost nothing to implement and block entire categories of attacks at the browser level [6].

2. Implement Content Security Policy

A Content Security Policy (CSP) tells the browser exactly which sources of scripts, styles, images, and other resources are permitted. It is the most effective defense against cross-site scripting (XSS).

Next.js does not set a default CSP. You need to configure one yourself. The recommended approach uses nonce-based policies generated in middleware:

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

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64')

  const cspHeader = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
    style-src 'self' 'nonce-${nonce}';
    img-src 'self' blob: data:;
    font-src 'self';
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    upgrade-insecure-requests;
  `.replace(/\s{2,}/g, ' ').trim()

  const response = NextResponse.next()
  response.headers.set('Content-Security-Policy', cspHeader)
  response.headers.set('x-nonce', nonce)

  return response
}

A nonce is a random string generated per request. Only scripts tagged with the matching nonce will execute. This blocks injected scripts even if an attacker finds a way to insert HTML into your page.

Key directives to understand:

  • default-src 'self' blocks all external resources unless explicitly allowed
  • script-src 'strict-dynamic' allows scripts loaded by trusted scripts to execute (necessary for code-splitting)
  • object-src 'none' blocks Flash and other plugin-based content entirely
  • frame-ancestors 'none' provides the CSP equivalent of X-Frame-Options: DENY

One important caveat: nonce-based CSP requires dynamic rendering. Static pages cannot use per-request nonces. If your app uses static generation for some routes, you will need a hash-based fallback for those pages.

3. Separate Server and Client Environment Variables

Next.js has a strict boundary: only variables prefixed with NEXT_PUBLIC_ are bundled into browser JavaScript. Everything else stays on the server. This boundary is your primary defense against credential leaks.

Structure your .env file with a clear separation:

# Public (safe for browser)
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...

# Server-only (never exposed to browser)
SUPABASE_SERVICE_ROLE_KEY=eyJ...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
RESEND_API_KEY=re_...

The rules are simple:

  • Safe to prefix with NEXT_PUBLIC_: Supabase anon key (protected by RLS), Supabase URL, your domain, analytics IDs, Stripe publishable key
  • Never prefix with NEXT_PUBLIC_: Stripe secret key, Supabase service role key, database URLs, email provider keys, webhook secrets, AI provider keys

We covered the full scope of this problem, including the Google $82K API key incident and Claude Code CVEs, in Exposed API Keys: How AI Tools Leak Your Secrets. The short version: there are no harmless keys anymore. A read-only mapping key today might access AI billing endpoints tomorrow.

As an additional safeguard, the Next.js docs recommend creating a Data Access Layer (DAL) that centralizes all server-side data fetching [5]. This prevents accidentally importing server-only code into a Client Component, which would either expose secrets or throw a build error.

4. Validate All Input with Zod

Every Server Action is a public HTTP POST endpoint. Anyone can call it with any payload. Client-side validation is a convenience for the user. Server-side validation is a security requirement.

Zod provides runtime type checking that catches malformed, oversized, or malicious input before it reaches your database:

// lib/schemas/auth.ts
import { z } from 'zod'

export const loginSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
})

export const signupSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  fullName: z.string().min(2, 'Name must be at least 2 characters'),
})

Then use the schema in every Server Action:

// actions/auth.ts
'use server'

import { loginSchema } from '@/lib/schemas/auth'

export async function login(data: LoginInput) {
  const parsed = loginSchema.safeParse(data)
  if (!parsed.success) {
    return { error: parsed.error.errors[0].message }
  }

  // Only use parsed.data from here — never the raw input
  const { email, password } = parsed.data
  // ...
}

The critical pattern: always use parsed.data after validation, never the original input. This ensures type coercion and sanitization are applied. For a deeper walkthrough of this pattern, see Next.js Server Actions + Zod: The Complete Guide to Type-Safe Form Validation.

Zod is not just for forms. Validate webhook payloads, URL parameters, API responses from third-party services, and any data crossing a trust boundary.

5. Protect Server Actions with Rate Limiting

Server Actions are POST endpoints. Without rate limiting, an attacker can call your login action thousands of times per second, brute-forcing passwords or burning through your Stripe API quota.

A basic in-memory rate limiter is better than no rate limiter:

// lib/rate-limit.ts
const rateLimitStore = new Map<
  string,
  { count: number; resetTime: number }
>()

export async function rateLimit(
  key: string,
  limit: number,
  windowSeconds: number
): Promise<{ success: boolean; remaining: number }> {
  const now = Date.now()
  const windowMs = windowSeconds * 1000
  const current = rateLimitStore.get(key)

  if (!current || now > current.resetTime) {
    rateLimitStore.set(key, {
      count: 1,
      resetTime: now + windowMs,
    })
    return { success: true, remaining: limit - 1 }
  }

  if (current.count >= limit) {
    return { success: false, remaining: 0 }
  }

  current.count++
  return { success: true, remaining: limit - current.count }
}

Apply it to every sensitive action:

// actions/auth.ts
'use server'

import { rateLimit } from '@/lib/rate-limit'

export async function login(data: LoginInput) {
  const { success } = await rateLimit('login', 5, 60)
  if (!success) {
    return { error: 'Too many attempts. Please try again later.' }
  }

  // Validate, then authenticate...
}

This limits login attempts to 5 per 60 seconds. For production applications serving multiple instances, replace the in-memory store with Redis or a service like Upstash. The in-memory approach works for single-instance deployments and is a significant improvement over no protection at all.

Rate limit these actions at minimum: login, signup, password reset, contact forms, and any action that calls a paid external API.

6. Use Backend-Only Data Access

This is the single most impactful security decision in a Next.js application. Backend-only data access means your database client with elevated privileges never runs in the browser.

Implement two separate Supabase clients:

// lib/supabase/client.ts — browser only, limited privileges
import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}
// lib/supabase/server.ts — server only, full privileges
import { createClient as createSupabaseClient } from '@supabase/supabase-js'

export function createAdminClient() {
  return createSupabaseClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
    {
      auth: {
        autoRefreshToken: false,
        persistSession: false,
      },
    }
  )
}

The rule: createClient() uses the anon key and runs in the browser. It can only access data that RLS policies allow. createAdminClient() uses the service role key and runs exclusively in Server Actions and API routes. It bypasses RLS entirely.

Never import createAdminClient in a Client Component. Next.js will either expose the service role key in the browser bundle or throw a build error, depending on your configuration. Neither outcome is acceptable. The official Next.js security guide recommends this exact separation pattern and suggests using the server-only package to enforce the boundary at build time [2].

7. Enable Row Level Security with Default Deny

Row Level Security (RLS) in Supabase controls which rows a user can read, insert, update, or delete. The safest configuration is to enable RLS on every table and add no policies. This creates a default-deny posture where the anon key has zero access:

-- Enable RLS on all tables (no policies = deny all to anon)
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.purchases ENABLE ROW LEVEL SECURITY;

-- Lock down trigger functions
REVOKE EXECUTE ON FUNCTION public.handle_new_user FROM public;
REVOKE EXECUTE ON FUNCTION public.handle_new_user FROM anon;

With this approach, all data operations go through createAdminClient() in Server Actions, where you control access through application logic. The anon key, even if exposed (it is public by design), cannot read or write any table.

This is more restrictive than the typical Supabase setup, where developers write RLS policies for each table. The tradeoff: you lose the ability to query directly from Client Components. The gain: you eliminate an entire class of authorization bugs. A misconfigured RLS policy can expose every row in a table. No policy at all means no misconfiguration.

For a detailed look at how RLS failures led to real breaches, see Why 170+ Vibe-Coded Apps Got Hacked.

8. Verify Webhook Signatures

Webhooks are unauthenticated HTTP requests from external services. Without signature verification, an attacker can forge a Stripe checkout completion, grant themselves access to your product, or trigger arbitrary business logic.

Always verify the signature before processing any webhook event:

// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import { NextResponse } from 'next/server'
import Stripe from 'stripe'

const relevantEvents = new Set([
  'checkout.session.completed',
  'customer.subscription.updated',
])

export async function POST(request: Request) {
  const body = await request.text()
  const headersList = await headers()
  const sig = headersList.get('stripe-signature')

  if (!sig) {
    return NextResponse.json({ error: 'No signature' }, { status: 400 })
  }

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    console.error('Webhook signature verification failed:', err)
    return NextResponse.json(
      { error: 'Invalid signature' },
      { status: 400 }
    )
  }

  if (!relevantEvents.has(event.type)) {
    return NextResponse.json({ received: true })
  }

  // Safe to process — signature verified
  // ...
}

Three details matter here. First, read the body as raw text with request.text(), not as JSON. Stripe signs the raw payload, and parsing it first changes the byte representation. Second, reject early if no signature header is present. Third, whitelist the event types you actually handle. Ignoring irrelevant events reduces your attack surface.

This pattern applies to every webhook provider: GitHub, Resend, Clerk, Paddle. Each has its own signing mechanism, but the principle is identical. Verify first, process second.

9. Add Authentication Middleware

Middleware (or proxy.ts in Next.js 16) runs before every matching request. It is the right place to enforce authentication at the route level, before any page code executes:

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

const protectedRoutes = ['/dashboard', '/settings', '/billing']
const authRoutes = ['/login', '/signup', '/reset-password']
const adminRoutes = ['/admin']

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

  // Create Supabase client with request cookies
  const supabase = createServerClient(/* ... */)
  const { data: { user } } = await supabase.auth.getUser()

  // Redirect unauthenticated users away from protected routes
  if (protectedRoutes.some(route => pathname.startsWith(route)) && !user) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  // Redirect authenticated users away from auth pages
  if (authRoutes.some(route => pathname.startsWith(route)) && user) {
    return NextResponse.redirect(new URL('/dashboard', request.url))
  }

  // Admin routes require specific email
  if (adminRoutes.some(route => pathname.startsWith(route))) {
    if (!user || !config.admin.superAdminEmails.includes(user.email!)) {
      return NextResponse.redirect(new URL('/dashboard', request.url))
    }
  }

  return NextResponse.next()
}

Middleware provides a first line of defense, but it is not sufficient on its own. It protects pages from being rendered, but it does not protect Server Actions from being called directly. An attacker can invoke any Server Action by sending a POST request, bypassing the page entirely [7]. This is why Step 10 is equally critical.

For a full walkthrough of Supabase authentication patterns in the App Router, see Supabase Authentication in Next.js App Router: The Complete 2026 Guide.

10. Authorize Every Server Action

Middleware protects routes. Authorization inside Server Actions protects operations. Both are required.

Every Server Action that performs a privileged operation must verify the user's identity and permissions before executing:

// actions/admin.ts
'use server'

import { getUser } from '@/lib/supabase/server'
import { config } from '@/config'

async function requireSuperAdmin() {
  const user = await getUser()
  if (!user || !config.admin.superAdminEmails.includes(user.email!)) {
    throw new Error('Unauthorized')
  }
  return user
}

export async function getAdminStats() {
  await requireSuperAdmin()

  const admin = createAdminClient()
  // Now safe to query admin data...
}

The requireSuperAdmin helper is reused across every admin action. If the check fails, the action throws before any data is accessed. This pattern prevents horizontal privilege escalation (user A accessing user B's data) and vertical privilege escalation (regular user accessing admin functions).

Next.js Server Actions include built-in CSRF protection by comparing the Origin header to the Host header [2]. If they do not match, the request is rejected. This protects against cross-site request forgery without requiring tokens. But CSRF protection does not replace authorization. A legitimate, authenticated user might still attempt to access resources they do not own.

11. Keep Dependencies Updated

The React Server Components RCE (CVE-2025-55182, CVSS 10.0) affected every Next.js app using the App Router in its default configuration [3]. The fix was a dependency update. If you were not tracking security advisories, you were vulnerable for days or weeks before patching.

Build dependency monitoring into your workflow:

  • Enable Dependabot or Renovate on your repository. Configure it to auto-merge patch updates and flag minor/major updates for review.
  • Subscribe to the Next.js security advisories at the official blog. The December 2025 RCE and January 2026 DoS were both disclosed there first.
  • Run npm audit as part of your CI pipeline. Fail the build on critical vulnerabilities. This catches known issues in transitive dependencies that you might not track directly.
  • Pin your lockfile. Commit package-lock.json or pnpm-lock.yaml and use npm ci (or pnpm install --frozen-lockfile) in CI. This ensures you are building the exact dependency tree you tested, not whatever is latest on the registry.

The January 2026 DoS vulnerability (CVE-2026-23864) was exploitable with a single request that triggered memory exhaustion [4]. The patch was available the same day. The window between disclosure and exploitation is measured in hours, not weeks.

12. Scan for Leaked Secrets

Manual code review does not catch leaked credentials reliably. Automated scanning at three levels provides defense in depth:

Pre-commit scanning. Tools like git-secrets or detect-secrets catch keys before they enter your git history. This is important because git history is permanent. A key that was committed for five seconds and then removed still lives in the repository forever.

CI pipeline scanning. Integrate TruffleHog or GitHub's built-in secret scanning into your CI pipeline. GitHub Advanced Security's push protection blocks commits containing detected secrets before they reach the remote.

Runtime billing alerts. Set spending alerts on every cloud provider and third-party service. Stripe, Supabase, OpenAI, and Anthropic all offer usage alerts. A billing anomaly is often the first sign of a compromised key.

Add this to your CI pipeline as a minimum:

# .github/workflows/security.yml
- name: Scan for secrets
  uses: trufflesecurity/trufflehog@main
  with:
    path: ./
    base: ${{ github.event.repository.default_branch }}
    extra_args: --only-verified

The --only-verified flag reduces false positives by checking whether detected keys are actually valid. This keeps the signal-to-noise ratio high so developers do not start ignoring alerts.

The Checklist

Here is the complete list. Print it, pin it, run through it before every production deployment.

  • HTTP security headers configured in next.config.ts (X-Frame-Options, HSTS, X-Content-Type-Options, Referrer-Policy)
  • Content Security Policy with nonce-based script and style directives
  • Environment variables separated: NEXT_PUBLIC_ only for genuinely public values, all secrets server-only
  • Zod validation on every Server Action, webhook handler, and API route
  • Rate limiting on login, signup, password reset, contact forms, and paid API calls
  • Backend-only data access enforced: createAdminClient() never imported in Client Components
  • Row Level Security enabled on every Supabase table with default-deny (no policies)
  • Webhook signatures verified before processing any event
  • Authentication middleware protecting all private routes
  • Authorization checks inside every Server Action that performs a privileged operation
  • Dependencies updated with Dependabot/Renovate enabled and npm audit in CI
  • Secret scanning with pre-commit hooks, CI scanning, and runtime billing alerts

Each step addresses a distinct attack vector. Skipping one does not make the others less valuable, but a chain is only as strong as its weakest link. The React Server Components RCE (CVSS 10.0) was exploitable in default configurations. The Supabase RLS misconfigurations that exposed 170+ apps were basic authorization failures. These were not sophisticated attacks. They exploited the step that someone skipped.

This is the architecture that SecureStartKit enforces out of the box: backend-only data access, Zod on every input, RLS default-deny, webhook verification, rate limiting, and security headers pre-configured. The checklist above is not aspirational. It is the baseline for shipping a Next.js application that does not become tomorrow's breach headline.

Built for developers who care about security

SecureStartKit ships with these patterns out of the box.

Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.

View PricingSee the template in action

References

  1. OWASP Top 10:2025 Introduction— owasp.org
  2. How to Think About Security in Next.js— nextjs.org
  3. Critical Security Vulnerability in React Server Components— react.dev
  4. CVE-2026-23864: React and Next.js Denial of Service via Memory Exhaustion— akamai.com
  5. Guides: Data Security | Next.js— nextjs.org
  6. Next.js Security Checklist— blog.arcjet.com
  7. Next.js Server Actions Security: 5 Vulnerabilities You Must Fix— makerkit.dev

Related Posts

Mar 3, 2026·Security

The Vibe Coding Security Checklist: How to Audit Your AI-Generated App

Vibe coding tools like Cursor and v0 build apps fast, but they often ship vulnerabilities. Here is the technical audit checklist for Next.js and Supabase apps.

Mar 12, 2026·Security

Exposed API Keys: How AI Tools Leak Your Secrets (And How to Lock Them Down)

Claude Code CVEs, Google's $82K API key incident, 5,000+ repos leaking ChatGPT keys. Learn how AI tools expose your secrets and how to lock them down in Next.js.

Feb 21, 2026·Security

Why 170+ Vibe-Coded Apps Got Hacked, And How to Actually Secure Your Supabase App

The Lovable hack exposed 170+ apps through missing RLS. Here's what went wrong and the exact steps to secure your Supabase database.