Treat every Next.js Server Action that starts or approves an AI-agent operation as a public mutation endpoint. Authenticate inside the action, authorize the exact tenant and resource, validate every client-controlled value, enforce rate and cost limits, require idempotency for side effects, and return a minimal DTO. A hidden button, unpredictable action ID, or model decision is not authorization.
Begin with Next.js’s threat model
Next.js’s data-security guide states that an exported, used Server Action is reachable through a direct POST request, not only through your rendered interface. Next.js creates encrypted non-deterministic action IDs and removes unused actions, but its documentation explicitly says those features do not replace authentication and authorization.
For AI applications, assume an attacker can:
- call the action without clicking the intended approval UI;
- alter form fields, bound arguments, URLs, headers, and hidden inputs;
- replay a request;
- submit another tenant’s run or tool-call ID;
- inject instructions into user text or retrieved content;
- race two approvals;
- disconnect after the side effect commits;
- force costly model or tool work repeatedly.
Server Actions reduce client plumbing. They do not change the security boundary.
Make actions thin and secure the data-access primitive
Put authorization and mutation logic in a server-only module, then expose a narrow action. The current use server reference recommends secure primitives that validate input, check authentication and authorization, and constrain return types.
// app/actions/approve-agent-action.ts
"use server";
import "server-only";
import { updateTag } from "next/cache";
import { approvePendingAction } from "@/data/agent-approvals";
export type ApprovalState =
| { status: "idle" }
| { status: "approved"; actionId: string }
| { status: "error"; code: "invalid" | "forbidden" | "conflict" };
export async function approveAgentAction(
_previous: ApprovalState,
formData: FormData,
): Promise<ApprovalState> {
const actionId = readOpaqueId(formData.get("actionId"));
const expectedVersion = readPositiveInteger(formData.get("version"));
const result = await approvePendingAction({ actionId, expectedVersion });
if (!result.ok) return { status: "error", code: result.code };
updateTag(`agent-action:${actionId}`);
return { status: "approved", actionId };
}
The data layer must read the actor from the current session, load the pending record, compare tenant and object permissions, verify the action is still pending at expectedVersion, enforce cost and tool policy, and commit approval plus audit event in one transaction. actionId and version are untrusted even if React supplied them through .bind or hidden fields.
Authorize the object, action, and parameters
Authentication answers who called. Authorization must answer all of these:
- May this actor access this tenant?
- May they approve this specific agent run?
- May this run invoke this tool and operation?
- Are the exact target, amount, destination, and scope allowed?
- Is separation of duties required?
- Has the policy or permission changed since the UI rendered?
Do the checks against current server-side state. Page-level redirects and disabled buttons improve UX but do not protect the action. Next.js’s guide specifically warns that a page authorization check does not carry into an action.
Prompt injection never grants authority. The model may propose a structured action, but trusted policy code validates its schema and allowed values, and a human approval must bind to the exact normalized operation. If a tool request changes after approval, require another approval.
Parse runtime data instead of asserting types
TypeScript annotations disappear at runtime. Validate form fields, JSON, filenames, URLs, tool arguments, and uploaded content with a runtime parser or small explicit functions. Reject unknown fields when they can change behavior. Normalize once, then authorize the normalized representation.
For URLs, resolve and validate destination policy on the server and again at execution time to prevent redirects or DNS changes from bypassing SSRF controls. For files, enforce content and size limits; do not trust MIME type or extension alone. For numeric costs and quantities, parse canonical integers or decimals with declared bounds.
Return stable error codes rather than raw exceptions, stack traces, provider messages, or database records. Server Action return values are serialized to the client; the Next.js security documentation recommends returning only what the UI needs.
Make side effects idempotent and auditable
Every high-impact attempt needs a server-generated logical operation ID and a uniqueness constraint at the side-effect boundary. Store:
- actor and tenant;
- normalized operation and target;
- policy and approval version;
- idempotency key;
- status: proposed, approved, dispatching, committed, failed, or unknown;
- provider/tool receipt;
- timestamps and correlation IDs.
On duplicate submission, return the existing state. On a timeout after dispatch, mark the effect unknown and reconcile with the external system; do not blindly retry. See human approval for AI agents and durable agent workflows for the broader state machine.
Use a database transaction or compare-and-set to ensure two concurrent approvals cannot both dispatch. A client-side pending state is not a lock.
Enforce cost, concurrency, and abuse controls
Starting an agent can consume model tokens, browser sessions, sandboxes, and paid tools. Apply per-user, per-tenant, per-operation, and global limits before reservation. Reserve budget atomically, settle actual usage, and release abandoned reservations safely.
Rate-limit by authenticated principal and relevant resource; IP-only limits punish shared networks and are easy to rotate. Bound fan-out and retries in trusted orchestration. The model cannot increase its own limit because the prompt says the task is urgent.
For large uploads, use an authenticated direct-upload flow with short-lived scoped credentials and pass the resulting object ID to the action. The current serverActions configuration reference documents a default 1 MB raw request-body limit and warns that multipart framing adds overhead. Raising that limit increases resource-exhaustion exposure.
Keep CSRF defenses narrow
Next.js compares the request Origin with the host and permits extra origins through serverActions.allowedOrigins. Configure only exact trusted proxy/application origins required by the deployment. Broad wildcards expand the CSRF trust boundary.
Same-origin checks are defense in depth, not authentication. Review cookie SameSite, Secure, and HttpOnly behavior with the authentication system. State-changing actions should use POST through the framework, and all authorization remains mandatory.
If several self-hosted instances serve the same build, review the Server Actions encryption-key guidance in the data-security guide. Closed-over values are sent to the client and back in encrypted form; Next.js explicitly says not to rely on encryption alone for sensitive data. Prefer reading secrets inside the server-only data layer instead of capturing them in an inline closure.
Invalidate only after a committed mutation
For read-your-own-writes, call updateTag after the transaction commits. If the mutation fails, do not invalidate or optimistically claim completion. Use revalidateTag(tag, "max") when stale-while-revalidate is acceptable rather than immediate consistency; the Next.js revalidation guide documents the distinction.
Cache invalidation is not an audit record. The durable database state remains authoritative.
Test actions as hostile endpoints
Test the data layer and the framework entry point:
- unauthenticated direct invocation;
- actor from another tenant;
- owned run but forbidden tool or target;
- tampered ID, version, amount, URL, and hidden field;
- stale approval after the proposal changes;
- duplicate and concurrent submissions;
- timeout before and after external commit;
- rate, concurrency, and cost exhaustion;
- oversized multipart body;
- untrusted proxy origin;
- return-value leakage and log redaction;
- permission revocation between render and submit.
Exercise the built production artifact because action IDs and closure encryption are build-dependent. Do not snapshot or hard-code action IDs as an API contract. Keep a Route Handler or dedicated service API when third-party clients need a stable protocol.
Roll out with a kill switch
Gate new high-risk actions by tenant and operation, canary with synthetic tools, monitor denied attempts, unknown outcomes, duplicate prevention, spend, and latency, and provide an external kill switch that disables dispatch while preserving investigation data. Rollback must not erase already committed side effects; reconciliation remains part of the incident plan.