Server Action security model

Treats every Server Action as a public POST endpoint that must authenticate and validate its own input.

Signature
experimental.serverActions: { allowedOrigins?: string[], bodySizeLimit?: number | string }
  • Added in Next.js v14.0.0

Parameters

ParameterTypeDescription
$allowedOriginsoptionalstring[]= same origin onlyExtra origin domains permitted to invoke Server Actions. Next.js compares the request's Origin header to the Host (or X-Forwarded-Host) and rejects mismatches; list proxy or CDN domains here. Wildcards such as '*.my-proxy.com' are accepted.
$bodySizeLimitoptionalnumber | string= '1mb'Maximum size of the request body sent to a Server Action, in bytes or any string the bytes package parses, such as 1000, '500kb' or '3mb'. It applies to the raw HTTP body including multipart boundaries and part headers.
$NEXT_SERVER_ACTIONS_ENCRYPTION_KEYoptionalstring (environment variable)= generated per buildBase64-encoded AES key (16, 24 or 32 decoded bytes) used to encrypt closed-over variables. Set it so every self-hosted instance shares one key; otherwise a key is generated per build.

Errors

Requests whose Origin does not match the Host (or X-Forwarded-Host) are rejected, bodies over bodySizeLimit are refused, and a client running an older build that calls a rotated action ID gets "Failed to find Server Action".

Changelog

  • PHP v16.0.0middleware.ts renamed to proxy.ts; a proxy matcher that excludes a path still skips the Server Actions on that path.
  • PHP v14.0.0Server Actions stable; encrypted action IDs, dead code elimination and the Origin/Host CSRF check apply by default.

A Server Action is not a private callback. At build time the 'use server' directive swaps the implementation in the client bundle for a reference — an action ID plus a dispatcher — and invoking it sends a POST to the route the action was rendered on. Anyone who can send that POST can reach the function, whether or not they went through your UI.

What the framework already does

Next.js compares Origin against Host and rejects mismatches, caps action bodies at 1MB, encrypts action IDs and closed-over variables, and strips unreferenced actions from the client bundle so they have no public endpoint at all.

What you still have to do

Read the session inside every action. A page that redirects unauthenticated users controls which UI renders; it is not a boundary around the action defined in it. Then check authorization, not just authentication — accept an id and re-read the row scoped to the session owner rather than trusting an object the client sent. Schema validation confirms shape, never ownership.

The subtle one: because actions are POSTs to a page route, a proxy.ts matcher that excludes a path also skips the actions on it. Moving an action to another route can silently remove that coverage.

Example

Example · typescript
'use server'

import { auth } from '@/lib/auth'
import { db } from '@/lib/db'

// Unsafe: no auth, no ownership check. The whole item, including its id, comes
// from the client, so anyone who can POST here can mark any item complete.
export async function completeItemUnsafe(item: Item) {
  await db.item.update({ where: { id: item.id }, data: { completed: true } })
}

// Safe: take only the change, derive identity from the session,
// and look the row up by ownership.
export async function completeItem(itemId: string) {
  const session = await auth()
  if (!session?.user) return

  const item = await db.item.findFirst({
    where: { id: itemId, ownerId: session.user.id },
  })
  if (!item) return

  await db.item.update({ where: { id: item.id }, data: { completed: true } })
}

When to use it

  • Re-verify the session inside a delete action that an admin page already gated.
  • Scope a database lookup by owner id so a forged record id fails.
  • Raise bodySizeLimit for an upload form and add allowedOrigins behind a reverse proxy.

Frequently asked questions

Can someone call my Server Action directly without using my form?

Yes — an exported and referenced Server Action is reachable by POST to the route it lives on, so treat it as a public endpoint. The encrypted, rotating action ID raises the bar but is obscurity, not an authorization check.

Does middleware or proxy.ts protect Server Actions?

Only if the action's route is inside the matcher, since actions are POSTs to the page route rather than routes of their own. Excluding a path from the matcher silently removes that protection, which is why every action should verify auth itself.

What causes "Failed to find Server Action"?

A browser still running an older build invoked an action ID that the current deployment no longer has, since IDs are regenerated on new builds and at most every 14 days. Prefer rolling deployments and surface the error as a retry prompt so a refresh recovers the user.

More examples

The page-level check that protects nothing

The redirect decides what renders; the action is a separate entry point reachable by POST regardless. The duplicated `auth()` call inside the action looks redundant and is the only thing actually enforcing the rule.

Example · typescript
// app/admin/page.tsx
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'

export default async function AdminPage() {
  const session = await auth()
  if (!session?.user?.isAdmin) {
    redirect('/login')
  }

  return (
    <form
      action={async () => {
        'use server'
        // Without these four lines, a direct POST to /admin runs the delete
        // even though the redirect above hides the button.
        const session = await auth()
        if (!session?.user?.isAdmin) {
          throw new Error('Unauthorized')
        }
        await db.record.deleteMany()
      }}
    >
      <button>Delete Records</button>
    </form>
  )
}

The proxy matcher gap

Server Functions are not separate routes in the routing chain, so excluding `/settings` from the matcher removes proxy coverage from the mutations defined there as well. Nothing warns about it, which is why the authorization check has to live inside each action rather than in the proxy.

Example · typescript
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  // Auth gate for the dashboard.
  if (!request.cookies.has('session')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
}

export const config = {
  // /settings was excluded to let its public preview render.
  // Every Server Action defined on /settings is now unguarded too,
  // because actions POST to the page route they live on.
  matcher: ['/((?!api|_next/static|_next/image|settings|favicon.ico).*)'],
}

Tuning the framework-level limits

Without `allowedOrigins`, a reverse proxy that rewrites Host makes every action fail the CSRF check with no obvious cause. The encryption key matters once more than one instance serves the app: each build otherwise generates its own, so closed-over variables encrypted by one instance cannot be decrypted by another.

Example · javascript
// next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
  experimental: {
    serverActions: {
      // Needed when the app is served through a proxy or CDN domain,
      // because Origin is compared against Host.
      allowedOrigins: ['my-proxy.com', '*.my-proxy.com'],
      // Raw body cap, multipart overhead included. Leave 10-20KB of
      // headroom above the largest upload you expect.
      bodySizeLimit: '2mb',
    },
  },
}

// Deployment environment, for self-hosting across multiple instances:
//   NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=$(openssl rand -base64 32)

Discussion

  • Be the first to comment on this lesson.