Next.js Server Actions — audit
TL;DR: Server Actions (
'use server'functions) are RPC endpoints that look like local function calls — but their visibility, authorization, and input validation are 100% the developer’s responsibility. Common bugs: actions referenced from any client component become unauthenticated public endpoints, no built-in CSRF (Next defends via origin check that has bypasses), parameter trust assumed because TypeScript “validates” inputs, and IDOR-by-default since the action runs as whoever called it. Complement to nextjs-middleware-cve-2025-29927.
What it is
Server Actions are a Next.js (and React) primitive that lets a function on the server be invoked from client code by reference. The framework wires up encrypted IDs, dispatch, and execution. The model is simpler than app/api/*/route.ts API routes, but the simplicity hides several footguns.
Threat-model summary
- Visibility: any exported
'use server'function in a module imported by a client component is exposed as a public RPC endpoint. Even if no UI references it. Removing the UI does not remove the endpoint. - Auth: there is no automatic auth check. Middleware runs but does NOT see Server Action calls’ parameters as form fields — it sees a POST to the page URL. Auth enforcement must be inside the action body.
- Validation: TypeScript types are compile-time hints, not runtime guards. Untyped or
anyinputs reach the body unchecked. - CSRF: Next compares
Originto allowed list (Host+experimental.serverActions.allowedOrigins). If misconfigured (e.g.,'*', missing in dev) → CSRF.
Bug patterns
1. Orphan action exposed as RPC
1
2
3
// app/actions/admin.ts
'use server';
export async function deleteAllUsers() { await db.user.deleteMany(); }
If any client component imports this file (even unused), the function is callable by anyone who can guess the action ID. Action IDs are deterministic from the call site → predictable on rebuild.
- Fix: gate with auth inside body; treat actions as public endpoints.
2. Missing auth check
1
2
3
4
'use server';
export async function updateProfile(id: string, data: ProfileInput) {
return db.profile.update({ where: { id }, data });
}
Anyone can update any profile. id is attacker-controlled.
- Fix: read auth session inside action, derive
idfrom session, never trust client-passed identifier.
3. Trusted FormData
1
2
3
4
5
'use server';
export async function newPost(form: FormData) {
const role = form.get('role');
await db.user.update({ where: { id: session.userId }, data: { role } });
}
Hidden form field role=admin works because FormData is just a wire format, not a trust boundary.
- Fix: only read fields explicitly granted to the user; ignore extras.
4. Mass assignment via spread
1
await db.user.update({ where: { id: session.userId }, data: { ...input } });
If input is typed Partial<User> but populated from form.get / JSON body, attacker sets any field — including isAdmin, emailVerified.
- Fix: explicit pick / zod schema with
.strict().
5. Server-side fetch within action (SSRF)
1
2
3
4
'use server';
export async function fetchPreview(url: string) {
return (await fetch(url)).text();
}
- Fix: IP allowlist, scheme allowlist, no redirect-follow, or proxy via a hardened service.
6. RSC props leaking sensitive data
Server Components serialise their props to the client as part of the RSC payload. Anything passed as a prop to a 'use client' child is visible to the browser. Common bug: passing user from server to client component, which includes passwordHash.
- Fix: strip server-only fields before passing across boundary; use a DTO.
7. CSRF via misconfig
experimental.serverActions.allowedOrigins accepts wildcards. ['*'] opens cross-origin invocation; ['localhost'] in dev that ships to prod is the classic mistake.
- Fix: explicit production origin; reject in dev too.
8. Encrypted action ID confusion
Next assigns each action an ID derived from source. Hot-reload or build-time changes can re-issue IDs; if a client has a stale ID, it may map to a different action on the server post-deploy. Audit for “stale tab” UX implications and version skew.
9. Open redirect via redirect()
1
2
3
4
'use server';
export async function login(form: FormData) {
redirect(form.get('next') as string);
}
redirect() accepts any URL — open redirect, optional XSS via javascript: URI.
- Fix: validate
nextagainst allowlist.
10. Revalidate / cache poisoning
revalidatePath / revalidateTag calls with user-controlled path/tag can be used to evict a critical cached page repeatedly → cache stampede / DoS. Less catastrophic but reportable.
Audit checklist
rg -n "'use server'" app/ lib/→ enumerate every action file.- For each action: does it call
auth()/getServerSession()/ equivalent inside the body? - For each action: does it pick fields explicitly from input, or spread?
- For each
redirect(...)andfetch(...): is the URL validated? next.config.js: checkexperimental.serverActions.allowedOrigins.- Grep for
dangerouslySetInnerHTML— XSS via Server Component output is still XSS.