Every Server Action is a public POST endpoint. The bundler hides the name; it does not hide the contract. Who can call the action is the network — the action must fail closed.
Implementation of actions: nextjs-server-actions. General authn/authz: authentication-authorization.
Threat model
| Threat | Why |
|---|---|
| Enumerable endpoint | Action IDs leak in the client bundle; POST can be forged |
| IDOR | id in FormData/bind comes from the attacker |
| CSRF / cross-origin | Authenticated browser + cross-site POST |
| Mass assignment | Spread of FormData into the update |
| Data leak | Return/throw with internal fields |
| Abuse | No rate limit on login, payment, invite |
Non-negotiable rules
- Authn + authz in the body of the action — middleware/layout is not enough (they can be bypassed).
- Fail closed — no session or no policy →
{ ok: false, code: "unauthorized"|"forbidden" }orforbidden(). - Never trust IDs/fields from the client — reload the resource and check ownership/tenant.
- Allowlist parsing — Zod schema (or equivalent); ignore extra fields.
- Minimal response — no hashes, tokens, PII, stack, SQL.
Authz inside the action
"use server";
export async function deletePost(formData: FormData) {
const user = await requireUser(); // cookies/session no server
const id = z.string().uuid().parse(formData.get("id"));
const post = await db.post.findUnique({ where: { id } });
if (!post || post.tenantId !== user.tenantId) {
return { ok: false as const, code: "not_found" }; // não vaze existência cross-tenant
}
if (post.authorId !== user.id && user.role !== "admin") {
return { ok: false as const, code: "forbidden" };
}
await db.post.delete({ where: { id } });
revalidatePath("/posts");
return { ok: true as const };
}
requireUser()in every authenticated mutation; public actions (e.g. newsletter) are an explicit exception + rate limit.- Distinguish 401 (anonymous) from 403 (authenticated without permission) internally; for multi-tenant IDOR prefer a uniform 404.
- Roles in JWT: revalidate critical permissions on the server (session/DB), not just the old claim.
CSRF, origin, and proxies
- Next.js checks
Origin/Host(and equivalent headers) in Server Actions in recent versions — do not disable. - Behind a reverse proxy: configure
serverActions.allowedOrigins(andexperimental.serverActionsif the project still uses the old key) with the real hosts. - Session cookies:
HttpOnly,Secure,SameSite=Lax(orStrictif the flow allows it).SameSite=Noneonly with a reason. - Do not mark actions as “CSRF-safe” just because they use POST; Origin + SameSite + authz together.
// next.config.ts — só os hosts do produto
experimental: {
serverActions: {
allowedOrigins: ["app.example.com", "www.example.com"],
},
},
Adjust to the config key of the repo's Next major version.
Hostile input
- FormData, JSON, and arguments of
.bindare controlled by the client (the runtime encrypts closures; that is not authz). - Forbidden:
db.update({ data: Object.fromEntries(formData) }). - Uploads: mime/size on the server; do not trust
typefrom the browser; store outside the webroot with opaque names. - Path/URL strings: allowlist; block
javascript:,//evil, open redirects.
What not to close / not to return
- Do not capture secrets, connection strings, or tokens in closures sent to the client.
- Do not pass from the server to the action via bind: huge objects, the complete user, admin flags "already checked".
- Explicit return/select. Errors:
codestable +messagesafe; logdigestinternally.
Rate limit and abuse
- Limit per action + identity (user id or IP) on login, signup, invite, payment, mail.
- Idempotency in financial mutations / jobs (
Idempotency-Keyor business key). - Reject large payloads (
serverActions.bodySizeLimitaligned with the actual upload).
Checklist per new action
- Session resolved on the server
- Policy/ownership on the resource (not just "is logged in")
- Schema allowlist; IDs revalidated
- No mass assignment
- Return without internal fields
- Revalidate only what the user can see
- Rate limit if it is expensive or authenticable by anonymous users
-
allowedOriginscorrect in the deploy (www, preview, proxy)
Anti-patterns
- Trusting a hidden button /
disabledin the UI - Auth only in
middleware.ts - Treating action ID as a secret
export async functiongeneric that acceptsop+payload(open RPC)dangerouslyAllowSVG/ user HTML without sanitizing in rich fields- Disabling origin verification because "the proxy broke"
Completion criteria
- Every action touched authenticates and authorizes on the server
- IDs/tenant without demonstrable IDOR
- Schema allowlist; no spread of FormData into the DB
- Origin/cookies/limits aligned with the deploy
- Errors and returns without leakage
- Abuse paths (auth, mail, payment) with limit or idempotency