cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Securing Server Actions

Call a Next.js Server Action without its page, then secure it: check the user, validate every argument with Zod, set allowedOrigins, limit rates, hide errors.

By the end, you can
  • 01
    See an action as an endpointCall a Server Action with a POST and its id, and explain why every action checks who is asking and every argument.
  • 02
    Know what Next.js protectsExplain what the Origin check, the body size limit, action ids and encrypted closures stop, and what they leave to your code.
  • 03
    Harden an actionValidate arguments with Zod, add a rate limit, set allowedOrigins behind a proxy, and keep error details on the server.

Calling an action without its page

Asha's order page in the tea shop has a Cancel order button, from Authorization and the data access layer. Its Server Action, cancel, gets the order's id. The button only shows on the visitor's own order, so this first version checks nothing:

The order page, with actions that trust their formsNext.js
app/orders/[id]/lib/
app/orders/[id]/actions.ts
"use server"; import { removeAllOrders, setBoxes, setStatus } from "@/lib/db"; // The select only offers 1 to 5 boxes, on the visitor's own order.export async function changeBoxes(formData: FormData) {  await setBoxes(Number(formData.get("orderId")), Number(formData.get("boxes")));} // The button only shows on the visitor's own order.export async function cancel(id: unknown) {  await setStatus(Number(id), "cancelled");  return { message: `Order ${id} is cancelled.` };} // For an admin page that was removed. No page calls it any more.export async function deleteAllOrders() {  await removeAllOrders();}
Recorded from Next.js 16.4.3

POST /orders/1200

Sent with the request
next-action 40db09f444a5619567970083af3266cf0765bbde64
content-type text/plain;charset=UTF-8

The body sent with the request

[3]
Response headers
cache-control no-cache, no-store, max-age=0, must-revalidate
content-type text/x-component
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1

The body that arrived, as it arrived: the RSC payload, one row per line

0:{"a":"$@1","q":"","i":false,"b":"uzMg0OKb_cNPwImPwTYHM"}
1:{"message":"Order 3 is cancelled."}

In app/orders/[id]/actions.ts, the file shown first, line 12 cancels whatever id arrives, and line 13 says so. The recording is a real Next.js 16.4.3 project. After asking for Asha's page, the recorder stopped being a browser and sent this, with no cookie at all:

The request a program sent (no page, no cookie)Text
POST /orders/1
next-action: 40db09f444a5619567970083af3266cf0765bbde64
content-type: text/plain;charset=UTF-8

[3]

Line 1 posts to Asha's order page. Line 2 is the next-action header with the id the build gave cancel; the page's own JavaScript sends the same header. Line 5, the body, is the list of arguments: 3, Ben's order. The Network tab shows the answer: 200, and the payload's last line, {"message":"Order 3 is cancelled."}. Ravi, opening Ben's order later, saw it cancelled.

Left, the request a program sent, in three parts: POST /orders/1, the header next-action with the cancel action's id, and the body [3]. An arrow leads to the server, which finds the function for that id, then to cancel(3), which answers Order 3 is cancelled. Below the request: no page, no button, no cookie.the requestPOST /orders/1next-action: 40db09f4…body: [3]no page, no button, no cookiethe id's functioncancel(3)Order 3 is cancelled.
A Server Action needs only a POST, its id in next-action, and its arguments in the body. Next.js finds the function for the id and calls it with the arguments. Nothing in the request has to come from the page.
Real-life analogyA kitchen's order hatch

In a busy restaurant, order slips come through a hatch into the kitchen, and each names a dish by its number. Waiters usually write them, but anyone standing near the hatch could push one through. A careful cook checks each slip before cooking: which table, and whether that table really ordered it.

In real life: Order slips come through a hatch into the kitchen
In Next.js: POSTs come to the server with an action's id
In real life: The slip names a dish by its number on the menu
In Next.js: next-action names the action by its id
In real life: Anyone in the dining room can push a slip through
In Next.js: Any program can send the POST
In real life: The cook checks the table number and the order before cooking
In Next.js: The action checks the user and every argument
The main idea

A Server Action is a public endpoint. Anyone can call it with any arguments, without the page. Inside every action, check who is asking, validate every argument, limit how often it runs, and return only what the page needs. Next.js adds a few protections of its own, and none of them knows who your visitor is.

Every action is a public endpoint

An endpoint is an address on a server that anything on the internet can send requests to. For a Server Action, the address is any page of the app and the action's id. The program needed the id, and it was easy to find:

  • The page's HTML holds it. Open the Network tab, pick /orders/1, and find "id":"40db09f444a5619567970083af3266cf0765bbde64" in a hidden field of the Cancel form. React puts it there so the form works before the JavaScript loads.
  • The page's JavaScript holds it too: the recording found the id in the files the page loads.

So anyone who can load a page can call every action that page uses. Next.js's docs put it plainly: a page that only shows a form to signed-in staff protects nothing, because the request can come without the form. Here are both versions of the actions file, called by the same program:

Playground: call the cancel action yourself
The order page, with this version of app/orders/[id]/actions.tsNext.js
app/orders/[id]/lib/
app/orders/[id]/actions.ts
"use server"; import { removeAllOrders, setBoxes, setStatus } from "@/lib/db"; // The select only offers 1 to 5 boxes, on the visitor's own order.export async function changeBoxes(formData: FormData) {  await setBoxes(Number(formData.get("orderId")), Number(formData.get("boxes")));} // The button only shows on the visitor's own order.export async function cancel(id: unknown) {  await setStatus(Number(id), "cancelled");  return { message: `Order ${id} is cancelled.` };} // For an admin page that was removed. No page calls it any more.export async function deleteAllOrders() {  await removeAllOrders();}
Recorded from Next.js 16.4.3

POST /orders/1200

Sent with the request
next-action 40db09f444a5619567970083af3266cf0765bbde64
content-type text/plain;charset=UTF-8

The body sent with the request

[3]
Response headers
cache-control no-cache, no-store, max-age=0, must-revalidate
content-type text/x-component
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1

The body that arrived, as it arrived: the RSC payload, one row per line

0:{"a":"$@1","q":"","i":false,"b":"uzMg0OKb_cNPwImPwTYHM"}
1:{"message":"Order 3 is cancelled."}
Try it yourself
Trusts the forms

Line 12 cancels whatever id arrives, and line 13 says so. The program with no cookie cancelled Ben's order: Order 3 is cancelled. Asha's "3 OR 1=1" turned into NaN, which matched no order, and the answer still claimed success.

Both versions were built and run with Next.js 16.4.3. The Network tab opens on the first call a program sent: POST /orders/1 with the cancel action's id and [3]. The two after it sent "3 OR 1=1" and 3 with Asha's cookie. Reset goes back to the first version.

The second version is the one to write. Its cancel:

app/orders/[id]/actions.ts: every argument checkedTypeScript
"use server"; import { z } from "zod";import { cancelOrder, changeOrderBoxes, getCurrentUser } from "@/lib/dal"; const boxesForm = z.object({  orderId: z.coerce.number().int().positive(),  boxes: z.coerce.number().int().min(1).max(5),});const orderId = z.number().int().positive(); export async function changeBoxes(formData: FormData) {  const input = boxesForm.safeParse({ orderId: formData.get("orderId"), boxes: formData.get("boxes") });  if (!input.success) return;  await changeOrderBoxes(input.data.orderId, input.data.boxes);} export async function cancel(id: unknown) {  if (!(await getCurrentUser())) return { message: "Sign in first." };  const checked = orderId.safeParse(id);  if (!checked.success) return { message: "That isn't an order number." };  return { message: await cancelOrder(checked.data) };} 
  • Line 19 asks who is calling, with getCurrentUser from lib/dal.ts. No session: Sign in first.
  • Line 20 checks the argument against the schema on line 10: a whole number above 0. Line 21 refuses anything else, like the text "3 OR 1=1".
  • Line 22 passes a checked number to cancelOrder, which asks getOrder whether this user may have the order. For Asha and order 3: There's no such order.

The arguments are typed unknown on purpose. TypeScript types describe what your own code passes; they can't stop what a program sends. unknown makes you check before you use it.

Validating every argument

Forms in Next.js used Zod, a library that checks a value against a description of what it should be, a schema, to show helpful messages. Here it does a second job: refusing what the page never offers. The order page has a form to change the number of boxes:

app/orders/[id]/page.tsx: three forms, three kinds of valueTSX
import { notFound } from "next/navigation";import { getOrder, placeOrderAgain, verifySession } from "@/lib/dal";import { changeBoxes } from "./actions";import CancelButton from "./cancel-button"; export const instant = false; export default async function OrderPage({ params }: PageProps<"/orders/[id]">) {  await verifySession();  const { id } = await params;  const order = await getOrder(Number(id));  if (!order) notFound();   // A Server Action written inside the page. It uses orderId, so Next.js sends orderId along, encrypted.  const orderId = order.id;  async function orderAgain() {    "use server";    await placeOrderAgain(orderId);  }   return (    <main>      <h1>Order {order.id}</h1>      <p>        {order.boxes} boxes of {order.tea}: {order.status}      </p>      <form action={changeBoxes}>        <input type="hidden" name="orderId" value={order.id} />        <label htmlFor="boxes">Boxes</label>        <select id="boxes" name="boxes" defaultValue={order.boxes}>          {[1, 2, 3, 4, 5].map((boxes) => (            <option key={boxes}>{boxes}</option>          ))}        </select>        <button>Change</button>      </form>      <CancelButton orderId={order.id} />      <form action={orderAgain}>        <button>Order again</button>      </form>    </main>  );} 

Lines 27 to 36 are the Change form. Line 28 is a hidden orderId field, and line 30 a select that offers 1 to 5 boxes. Line 37 is CancelButton, which binds the order's id to cancel:

app/orders/[id]/cancel-button.tsx: the id, boundTSX
"use client"; import { useActionState } from "react";import { cancel } from "./actions"; export default function CancelButton({ orderId }: { orderId: number }) {  const [state, formAction, isPending] = useActionState(cancel.bind(null, orderId), { message: "" });  return (    <form action={formAction}>      <button disabled={isPending}>Cancel order</button>      <p aria-live="polite">{state.message}</p>    </form>  );} 

Line 7, cancel.bind(null, orderId), makes a new action whose first argument is always this order's id. That id still travels like any argument: in the page's HTML, the Cancel form's hidden field holds [1,{"message":""}], the bound id and useActionState's first state, in plain text. Each row is one value the actions receive, and who controls it:

Who controls each value
ValueHow it travelsCan the visitor change it?
The boxes selectIn the form's fields, as text.Yes. The select offers 1 to 5; the recorder sent -3.
The hidden orderId fieldIn the form's fields, as text.Yes. The recorder changed it from 1 to 3.
The id bound with cancel.bind(null, orderId)As the action's first argument. In the HTML, as plain text: [1,{"message":""}].Yes. A program sent 3 and "3 OR 1=1".
The orderId the inline action usesEncrypted, in a hidden field, sent back as it came.Not without the key. The same encrypted value can be sent again.
The userIn the signed, httpOnly session cookie, read with getCurrentUser.Not without the session secret.

The recorder posted the Change form three times on Asha's order, the way a browser sends it before the JavaScript loads: with 4 boxes, with -3, a number the select never offers, and with 5 boxes and the hidden orderId changed to 3. Then Ravi opened Ben's order:

Playground: change the form's fields
The order page, with this version of app/orders/[id]/actions.tsNext.js
app/orders/[id]/lib/
app/orders/[id]/actions.ts
"use server"; import { removeAllOrders, setBoxes, setStatus } from "@/lib/db"; // The select only offers 1 to 5 boxes, on the visitor's own order.export async function changeBoxes(formData: FormData) {  await setBoxes(Number(formData.get("orderId")), Number(formData.get("boxes")));} // The button only shows on the visitor's own order.export async function cancel(id: unknown) {  await setStatus(Number(id), "cancelled");  return { message: `Order ${id} is cancelled.` };} // For an admin page that was removed. No page calls it any more.export async function deleteAllOrders() {  await removeAllOrders();}
Recorded from Next.js 16.4.3
localhost:3000/orders/3Tea shop
Teas
Order 3

5 boxes of Smoky lapsang: cancelled

12345Change
Cancel order

Order again

Sent with cookie: session=eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ1MyIsImlhdCI6MTc5MzMxODQwMCwiZXhwIjo0MTAyNDQ0ODAwfQ.3aFaYWiIbytBID0dmkkjpN4mgpaQkR6Mkq5PqxEDRQQ. The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

Try it yourself
Trusts the forms

Line 7 saves whatever numbers arrive. After the three posts, Ben's order, opened by Ravi, has 5 boxes and is cancelled, and Asha's order had -3 boxes until the last request emptied the table.

Both versions were built and run with Next.js 16.4.3. The recorder posted the Change form on Asha's order three times: 4 boxes, -3 boxes, and 5 boxes with the hidden orderId changed to 3. Then it opened Ben's order with Ravi's cookie. Reset goes back to the first version.

In the checked version, lines 6 to 9 describe the form: z.coerce.number() turns the field's text into a number, .int() wants a whole one, and .min(1).max(5) the select's range. Line 13 checks both fields at once, and line 14 stops on any failure. The changed orderId passed the schema, since 3 is a fine number, so the ownership check in changeOrderBoxes stopped it. A schema checks the shape of a value; only the data access layer knows whether this user may use it. Every action needs both.

Return nothing the page doesn't show

An action's return value is sent to the browser too. cancel returns { message }, never the order's row, for the same reason getOrder returns a DTO.

Encrypted closures and action ids

Next.js does protect some things on its own. Two of them are easy to mistake for security they aren't.

Values an inline action uses are encrypted

Lines 16 to 19 of the order page write a Server Action inside the page component. A function that uses a variable from around it is a closure; orderAgain uses orderId, from line 15. When it runs later, on another request, it needs that value back, so Next.js puts it into the page, encrypted with the build's key. In the recorded HTML, the Order again form's hidden field holds a long string that starts "zAsL7W, not the number. The recorder called the action with a made-up string in its place: Next.js couldn't decrypt it, answered 500, and the action never ran.

Two halves. Left, an id bound with bind: the page's HTML holds it as plain text, 1, a program sent 3 instead, and cancel(3) ran. Right, an id an inline action uses: the HTML holds it encrypted, a made-up value can't be decrypted, and the answer is 500. Below both: either way, the action still asks who is calling.bound with .bind()[1,{"message":""}]a program sent 3cancel(3) ranused by an inline actionan encrypted stringa made-up value sentcan't decrypt: 500either way: the action asks getCurrentUser
A bound argument is just the action's first argument, in plain text. A value an inline action uses is encrypted with the build's key, so nobody can read or change it. Neither says who is calling: the action still asks the session.

That protects the value: nobody can read it or change it. It doesn't say who is calling. Anyone who loads the page gets a valid encrypted value, and can send it back as often as they like. So placeOrderAgain still asks getCurrentUser, and the docs advise against relying on the encryption to hide sensitive values. When the app runs on several servers, every one needs the same key: the docs say to set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY to a secret, random 32-byte key (openssl rand -base64 32), the same on every server that runs one build.

Action ids are made from a secret

The docs call action ids "encrypted, non-deterministic". In Next.js 16.4.3, the compiler makes each id from one secret, the file's path and the function's name: a SHA-1 hash, a short fingerprint of those three, with one more byte in front about the function's arguments (server_actions.rs, lines 284 to 306). The secret is the encryption key: Next.js makes a random one and keeps it in .next/cache for 14 days, or makes a new one for every build when it runs in Docker (encryption-utils-server.ts, line 14), or uses NEXT_SERVER_ACTIONS_ENCRYPTION_KEY.

The examples here set that variable to a fixed key, written in their .env files. That's a fixture for the recording, not a security measure: it keeps the ids the same on every build, so the recorder could send them and this course's test can work them out. Anyone who knows a key can work out the ids it gives. A real app keeps its key secret.

What the secret protects: nobody without the key can work out the id of an action they've never seen. The docs add that Next.js removes unused actions from the browser's code, so they have no public endpoint. The trusting version tests that in one case. It still exports deleteAllOrders, on lines 17 to 19, which no page calls any more, from the same file as changeBoxes and cancel, which pages do use. Its id appears in none of the files the page loads. But with Next.js 16.4.3's default build, made by Turbopack, it was still an endpoint: called with its id, it answered 200, and Asha's order was gone. The docs' wording may hold in other setups, like a webpack build; this recording shows only the default one. In the checked version, the function is deleted, and the same call got 409, Server Action unavailable.

So don't count on "nothing calls it" meaning "nobody can reach it". Give every action, and above all a destructive one, its own checks, or delete it.

What Next.js protects, and what it leaves to you
ProtectionWhat it stopsWhat it doesn't stop
Comparing Origin with the hostAnother site's page posting to the action from a visitor's browser.A request with no Origin, which it lets through (a form post gets a warning), or the visitor's own browser.
A body size limit, 1 MB by defaultA huge body, before your code reads it.Many small requests.
Ids made with a secret keyGuessing the id of an action no page uses, without the key.Calling an action whose id is in a page anyone can load. With the default build, an unused export of a used actions file still ran.
Encrypting what an inline action usesReading or changing the captured value.Sending the same encrypted value again, from anywhere.
A digest instead of a thrown error's messageThe message reaching the browser.A message your action returns itself.

Origin checks, allowedOrigins and bodySizeLimit

One attack doesn't need a program. A page on another site can hold a form that posts to your action. When a visitor who is signed in to the tea shop opens that page, their browser sends the form, perhaps with their cookies. That's cross-site request forgery (CSRF). The session cookie's sameSite: "lax", from Authentication, already keeps the cookie off other sites' POSTs; it still goes with a link the visitor follows to the shop.

Next.js adds a check of its own. Modern browsers normally send an Origin header with a POST: the site of the page that sent it. Next.js compares it with the address the request was sent to, the Host header, or X-Forwarded-Host when a proxy in front of the app set it. If they differ, the action doesn't run. A request with no Origin at all is let through: for a posted form, Next.js only prints a warning. A missing Origin proves nothing about where a request came from, and any program can leave it out.

The shop has a contact form, and its action prints a line for each question it gets. The recorder posted the form four times, as Chrome would from four places, each with the Origin a browser sends, then once more from a hand-made request with no Origin. Pick each version and read the Terminal tab under $ next start:

Playground: where may an action be called from?
The contact form, with this versionNext.js
app/contact/
app/contact/actions.ts
"use server"; import { z } from "zod"; const question = z.object({ email: z.email(), text: z.string().trim().min(1).max(500) }); export async function askQuestion(formData: FormData) {  const input = question.safeParse({ email: formData.get("email"), text: formData.get("text") });  if (!input.success) {    console.log("askQuestion: refused, the fields don't match the schema");    return;  }  console.log(`askQuestion: ${input.data.text.length} characters from ${input.data.email}`);}
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Environments: .env- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (4/4)  Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found└ ○ /contact ○  (Static)  prerendered as static content $ next start▲ Next.js 16.4.3- Local:         http://localhost:3000- Network:       http://localhost:3000✓ ReadyaskQuestion: 19 characters from asha@example.com`x-forwarded-host` header with value `shop.example` does not match `origin` header with value `evil.example` from a forwarded Server Actions request. Aborting the action.⨯ Error: Invalid Server Actions request.    at async m (.next/server/chunks/ssr/[root-of-the-server]__14ysbpjp-8wul._.js:1:12146)    at async n (.next/server/chunks/ssr/[root-of-the-server]__14ysbpjp-8wul._.js:2:4598)    at async t (.next/server/chunks/ssr/[root-of-the-server]__14ysbpjp-8wul._.js:2:6019)    at async Y (.next/server/chunks/ssr/[root-of-the-server]__14ysbpjp-8wul._.js:4:1514)⨯ Error: Invalid Server Actions request.    at async m (.next/server/chunks/ssr/[root-of-the-server]__14ysbpjp-8wul._.js:1:12146)    at async n (.next/server/chunks/ssr/[root-of-the-server]__14ysbpjp-8wul._.js:2:4598)    at async t (.next/server/chunks/ssr/[root-of-the-server]__14ysbpjp-8wul._.js:2:6019)    at async Y (.next/server/chunks/ssr/[root-of-the-server]__14ysbpjp-8wul._.js:4:1514)`x-forwarded-host` header with value `tea-shop.internal` does not match `origin` header with value `shop.example` from a forwarded Server Actions request. Aborting the action.⨯ Error: Invalid Server Actions request.    at async m (.next/server/chunks/ssr/[root-of-the-server]__14ysbpjp-8wul._.js:1:12146)    at async n (.next/server/chunks/ssr/[root-of-the-server]__14ysbpjp-8wul._.js:2:4598)    at async t (.next/server/chunks/ssr/[root-of-the-server]__14ysbpjp-8wul._.js:2:6019)    at async Y (.next/server/chunks/ssr/[root-of-the-server]__14ysbpjp-8wul._.js:4:1514)⨯ Error: Invalid Server Actions request.    at async m (.next/server/chunks/ssr/[root-of-the-server]__14ysbpjp-8wul._.js:1:12146)    at async n (.next/server/chunks/ssr/[root-of-the-server]__14ysbpjp-8wul._.js:2:4598)    at async t (.next/server/chunks/ssr/[root-of-the-server]__14ysbpjp-8wul._.js:2:6019)    at async Y (.next/server/chunks/ssr/[root-of-the-server]__14ysbpjp-8wul._.js:4:1514)askQuestion: refused, the fields don't match the schema⚠ Missing `origin` header from a forwarded Server Actions request.askQuestion: 19 characters from asha@example.com
Try it yourself
The default settings

With no serverActions settings, only the host may post. The page on evil.example and the proxy's request were both refused with 500, and next start printed why. The 2,000-character question reached the action, whose schema refused it. The hand-made post with no Origin ran, with a warning.

Both versions were built and run with Next.js 16.4.3. The recorder posted the contact form four times, as browsers would from four places: the shop's page, a page on evil.example, the shop's page through a proxy, and the shop's page with a 2,000-character question. The Terminal tab shows what next start printed. Reset goes back to the first version.
app/contact/actions.ts: one line on the server for each questionTypeScript
"use server"; import { z } from "zod"; const question = z.object({ email: z.email(), text: z.string().trim().min(1).max(500) }); export async function askQuestion(formData: FormData) {  const input = question.safeParse({ email: formData.get("email"), text: formData.get("text") });  if (!input.success) {    console.log("askQuestion: refused, the fields don't match the schema");    return;  }  console.log(`askQuestion: ${input.data.text.length} characters from ${input.data.email}`);} 

Line 13 prints askQuestion: 19 characters from asha@example.com for the shop's own page. The page on evil.example got 500, and next start printed `x-forwarded-host` header with value `shop.example` does not match `origin` header with value `evil.example`… and Invalid Server Actions request. The action printed nothing. The hand-made request with no Origin got 200: next start printed ⚠ Missing `origin` header from a forwarded Server Actions request., then the action's own line. The check is in action-handler.ts, lines 731 to 745. The Origin check stops another site's page in a visitor's browser; it was never a check of who is asking.

allowedOrigins: behind a proxy

Some apps run behind a proxy that forwards each request with its own address, here tea-shop.internal, while visitors' browsers are on shop.example. Then every real post looks cross-site, and the default version refused it too. serverActions.allowedOrigins lists the extra sites allowed to post:

next.config.ts: allowedOrigins and bodySizeLimitTypeScript
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  /* config options here */  cacheComponents: true,  partialPrefetching: true,  experimental: {    serverActions: {      allowedOrigins: ["shop.example"],      bodySizeLimit: "1kb",    },  },}; export default nextConfig; 

Line 9 lets pages on shop.example post, and the proxy's request got through. Pages on evil.example are still refused. The setting lives under experimental.serverActions in Next.js 16.4.3, as the docs show it. A proxy that forwards the visitor's host in X-Forwarded-Host needs no entry. And a list like this is for real proxies and domains: every site on it may post with your visitors' browsers.

bodySizeLimit

Next.js reads at most 1 MB of an action's body by default, so nobody can make the server read a huge one. Line 10 lowers the limit to 1 kB, only to make it easy to see with a small request. The 2,000-character question shows the difference. With the default limit, it reached the action, and the schema on line 5 refused it: askQuestion: refused…. With 1 kB, Next.js stopped it first: 500, and Body exceeded 1kb limit. Raise the limit for actions that take uploads; keep the schema's own limits either way.

A request on its way to an action passes four checks Next.js makes, one under the other: the Origin matches the host, else 500; the body is under the limit, else 500; the id names an action, else 409; captured values decrypt, else 500. Below them, the action itself must check who is asking, every argument, and how often.Next.js, before your codeOrigin matches the hostelse 500the body is under the limitelse 500the id names an actionelse 409captured values decryptelse 500your action: who is asking? every argument? how often?
Next.js makes four checks before your code runs, and refuses with the status beside each. None of them knows who your visitor is, what an order number may be, or how often someone may try. Those three are the action's own checks.

Rate limiting

Every check so far passes a well-formed request from a signed-in user. Nothing stops that user, or a program, from sending it a thousand times: guessing passwords, or sending a thousand emails. A rate limit allows a number of tries per key, like an email or an address, in a span of time. Next.js has none for actions, so here is the smallest one that works:

lib/rate-limit.ts: tries per key, in this server's memoryTypeScript
import "server-only"; // Tries per key, kept in this one server's memory. With more than one server, or a// serverless platform, each copy has its own Map: use a shared store, like Redis.const tries = new Map<string, number[]>(); export function allowTry(key: string, limit: number, windowMs: number) {  const now = Date.now();  const recent = (tries.get(key) ?? []).filter((time) => now - time < windowMs);  if (recent.length >= limit) return false;  recent.push(now);  tries.set(key, recent);  return true;} 
  • Line 5 keeps a list of times for each key.
  • Line 9 keeps the times inside the window. Line 10 refuses when there are already limit of them.
  • Lines 11 and 12 count this try, and line 13 allows it.

Lines 3 and 4 say what it is: a stand-in for teaching. The Map lives in one server's memory. Run three copies of the app, or a serverless platform that starts new ones, and each counts on its own. Real apps keep the counts in a store every copy shares, like Redis. The Authentication lesson's sign-in form uses it, five tries per email every 15 minutes:

The sign-in form, with a rate limitNext.js
app/sign-in/lib/
app/sign-in/actions.ts
"use server"; import { redirect } from "next/navigation";import { z } from "zod";import { NO_USER_HASH, verifyPassword } from "@/lib/password";import { allowTry } from "@/lib/rate-limit";import { createSession } from "@/lib/session";import { findUserByEmail } from "@/lib/users"; const credentials = z.object({ email: z.email(), password: z.string().min(1).max(200) }); export type SignInState = { error: string; email: string }; export async function signIn(previous: SignInState, formData: FormData): Promise<SignInState> {  const email = String(formData.get("email") ?? "").trim().toLowerCase();  const result = credentials.safeParse({ email, password: formData.get("password") });  if (!result.success) return { error: "Write your email and your password.", email };  if (!allowTry(`sign-in:${result.data.email}`, 5, 15 * 60 * 1000)) {    return { error: "Too many tries. Try again in 15 minutes.", email };  }  const user = await findUserByEmail(result.data.email);  const matches = await verifyPassword(result.data.password, user?.passwordHash ?? NO_USER_HASH);  if (!user || !matches) return { error: "Wrong email or password.", email };  await createSession(user.id);  redirect("/account");}
Recorded from Next.js 16.4.3
localhost:3000/sign-inTea shop
Teas
Sign in
asha@example.comSign in

Too many tries. Try again in 15 minutes.

Submitted the form with email: asha@example.compassword: guess-6, as a browser does before the page's JavaScript has loaded. The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

Lines 18 to 20 of app/sign-in/actions.ts come before the password check, so a refused try costs no hashing. The recorder signed in as Asha with six wrong passwords, then the right one. Tries 1 to 5 got Wrong email or password. The Browser tab opens on try 6: Too many tries. Try again in 15 minutes. Try 7, with the right password, got the same answer, so a guesser can't tell when they've found it. Ben, on another email, signed in at once: 303 to /account.

A key per email protects one account from many guessers. A key per address, the visitor's IP, slows one guesser trying many accounts; behind a proxy it comes from a header like x-forwarded-for, which headers() can read. Most apps use both, and pick limits that a person never meets.

A per-email limit has a cost: anyone who knows Asha's email can use up her tries and keep her out for 15 minutes. Apps soften that by limiting each client too, so one guesser can't lock many accounts, and by offering another way in, like an emailed link. This Map also keeps every key forever. A production limiter counts in a shared store with an atomic increment, so two servers can't both count the same try as the fifth, and lets each count expire with its window.

Errors that tell nothing

When an action fails, the message can say too much: a database's address, a user name, a file path. The review form's database is down today. Here are two ways to write its action:

Playground: what reaches the browser when an action fails?
The review form, with this version of app/reviews/actions.tsNext.js
app/reviews/lib/
app/reviews/actions.ts
"use server"; import { saveReview } from "@/lib/reviews"; export async function postReview(text: unknown) {  try {    await saveReview(String(text));    return { error: "" };  } catch (error) {    return { error: String(error) };  }}
Recorded from Next.js 16.4.3

POST /200

Sent with the request
next-action 409662aba8cbc2fbdbcb61b3a1f8c53736b79c7d78
content-type text/plain;charset=UTF-8

The body sent with the request

["Lovely."]
Response headers
cache-control no-cache, no-store, max-age=0, must-revalidate
content-type text/x-component
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1

The body that arrived, as it arrived: the RSC payload, one row per line

0:{"a":"$@1","q":"","i":false,"b":"Qo2uFa-BgWiHjYdhH5hNO"}
1:{"error":"Error: connect ECONNREFUSED 10.0.0.5:5432: password authentication failed for user \"shop_admin\""}
Try it yourself
Returns the error

Line 10 turns the error into text and returns it. The answer is 200, and the RSC payload carries the database's address, its port and the user name shop_admin.

Both versions were built and run with next build and next start, in production mode, with Next.js 16.4.3. The recorder called postReview the way the form's JavaScript does, while the reviews database was down. Reset goes back to the first version.
app/reviews/actions.ts that returns the errorTypeScript
"use server"; import { saveReview } from "@/lib/reviews"; export async function postReview(text: unknown) {  try {    await saveReview(String(text));    return { error: "" };  } catch (error) {    return { error: String(error) };  }} 

Line 9 catches the error, and line 10 sends its text back as the action's answer. The recorded payload carries connect ECONNREFUSED 10.0.0.5:5432 and shop_admin: the database's address and its user, for anyone who posts a review.

In the second version, the error is thrown. In production, Next.js doesn't send a thrown error's message to the browser. It sends a digest, a number that names this error, and the status 500. The Terminal tab shows the whole error under $ next start, with digest: '…' beside it, so the logs can find the error a visitor reports (Error handling shows what the page draws). The docs say next dev does send the message, to help while you work.

Better than both: check the argument, catch the failures you expect, write the details to your logs, and return a plain message the form can show:

postReview with a checked argument and a plain message (not recorded)TypeScript
const review = z.string().trim().min(1).max(500); export async function postReview(text: unknown) {  const checked = review.safeParse(text);  if (!checked.success) return { error: "Write a review of 1 to 500 characters." };  try {    await saveReview(checked.data);    return { error: "" };  } catch (error) {    console.error("postReview failed:", error);    return { error: "We couldn't save your review. Try again later." };  }}

Lines 4 and 5 check the argument first, against the schema on line 1, as every action should. Line 10 keeps the details of an expected failure on the server, and line 11 tells the visitor what they need to know.

Who protects what?

Next.js does some of the work; your actions do the rest. Sort each protection:

Who protects what?
  • Refusing a POST whose Origin doesn't match the host
  • Making sure someone is signed in before an order is cancelled
  • Making sure order 3 belongs to the visitor
  • Refusing a 2 MB body before the action reads it
  • Refusing -3 boxes
  • Keeping the value an inline action uses unreadable
  • Allowing five sign-in tries per email every 15 minutes
  • Keeping a thrown error's message out of the answer
Try it yourself
0 of 8 correct

Sort each protection: does Next.js do it for you, or must your action's code do it?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "Only my form can call my action." No. A program with the id and no cookie cancelled Ben's order.
  • "Action ids are secret, so they protect the action." No. Every page that uses an action carries its id. The secret only hides actions no page uses.
  • "A bound argument is safe, because my code chose it." No. It's the action's first argument, in plain text, and anyone can send another.
  • "The select only offers 1 to 5, so boxes is 1 to 5." No. The form is a suggestion; the recorder sent -3.
  • "TypeScript types check the arguments." No. Types disappear when the code is built. Check with a schema.
  • "An action nothing calls can't be reached." Its id stays out of the page, but with the default build an unused export in a used actions file still ran when called. Check it, or delete it.
  • "A request with no Origin header didn't come from another site." No. A missing Origin proves nothing: the hand-made post without one ran, with only a warning. Check who is asking inside the action.
  • "An in-memory rate limit is enough." Only on one server. Each copy of the app counts on its own.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upA program with no cookie

A program sends this cancel its id and [3], with no cookie. What message does it get back?

Starter codeTypeScript
"use server";

import { z } from "zod";
import { cancelOrder, changeOrderBoxes, getCurrentUser } from "@/lib/dal";

const boxesForm = z.object({
  orderId: z.coerce.number().int().positive(),
  boxes: z.coerce.number().int().min(1).max(5),
});
const orderId = z.number().int().positive();

export async function changeBoxes(formData: FormData) {
  const input = boxesForm.safeParse({ orderId: formData.get("orderId"), boxes: formData.get("boxes") });
  if (!input.success) return;
  await changeOrderBoxes(input.data.orderId, input.data.boxes);
}

export async function cancel(id: unknown) {
  if (!(await getCurrentUser())) return { message: "Sign in first." };
  const checked = orderId.safeParse(id);
  if (!checked.success) return { message: "That isn't an order number." };
  return { message: await cancelOrder(checked.data) };
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upThe action nobody calls

    The checked version deleted deleteAllOrders. A program still sends its old id. What status does it get?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWhich rule refuses -3?

      The Change form arrives with boxes set to -3. Which line of the schema refuses it?

      Starter codeTypeScript
      "use server";
      
      import { z } from "zod";
      import { cancelOrder, changeOrderBoxes, getCurrentUser } from "@/lib/dal";
      
      const boxesForm = z.object({
        orderId: z.coerce.number().int().positive(),
        boxes: z.coerce.number().int().min(1).max(5),
      });
      const orderId = z.number().int().positive();
      
      export async function changeBoxes(formData: FormData) {
        const input = boxesForm.safeParse({ orderId: formData.get("orderId"), boxes: formData.get("boxes") });
        if (!input.success) return;
        await changeOrderBoxes(input.data.orderId, input.data.boxes);
      }
      
      export async function cancel(id: unknown) {
        if (!(await getCurrentUser())) return { message: "Sign in first." };
        const checked = orderId.safeParse(id);
        if (!checked.success) return { message: "That isn't an order number." };
        return { message: await cancelOrder(checked.data) };
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeSomeone else's account

        Asha's email has used up its tries. Ben now signs in with his right password. What does he get?

        Starter codeTypeScript
        "use server";
        
        import { redirect } from "next/navigation";
        import { z } from "zod";
        import { NO_USER_HASH, verifyPassword } from "@/lib/password";
        import { allowTry } from "@/lib/rate-limit";
        import { createSession } from "@/lib/session";
        import { findUserByEmail } from "@/lib/users";
        
        const credentials = z.object({ email: z.email(), password: z.string().min(1).max(200) });
        
        export type SignInState = { error: string; email: string };
        
        export async function signIn(previous: SignInState, formData: FormData): Promise<SignInState> {
          const email = String(formData.get("email") ?? "").trim().toLowerCase();
          const result = credentials.safeParse({ email, password: formData.get("password") });
          if (!result.success) return { error: "Write your email and your password.", email };
          if (!allowTry(`sign-in:${result.data.email}`, 5, 15 * 60 * 1000)) {
            return { error: "Too many tries. Try again in 15 minutes.", email };
          }
          const user = await findUserByEmail(result.data.email);
          const matches = await verifyPassword(result.data.password, user?.passwordHash ?? NO_USER_HASH);
          if (!user || !matches) return { error: "Wrong email or password.", email };
          await createSession(user.id);
          redirect("/account");
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeBehind a proxy

          Behind a proxy that forwards tea-shop.internal, every post from shop.example gets 500. What should this config add?

          Starter codeTypeScript
          import type { NextConfig } from "next";
          
          const nextConfig: NextConfig = {
            /* config options here */
            cacheComponents: true,
            partialPrefetching: true,
            experimental: {
              serverActions: {
                bodySizeLimit: "1kb",
              },
            },
          };
          
          export default nextConfig;
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeFind the leak

            Which line sends the database's address and user name to anyone who posts a review?

            Starter codeTypeScript
            "use server";
            
            import { saveReview } from "@/lib/reviews";
            
            export async function postReview(text: unknown) {
              try {
                await saveReview(String(text));
                return { error: "" };
              } catch (error) {
                return { error: String(error) };
              }
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask what the request carried, what Next.js checked, and what the action checked itself.

              The securing Server Actions quiz · 8 questionsScore: first tries count
              1. Question 1 of 8No page shows Ben's order to anyone but Ben. A program sends POST /orders/1 with the cancel action's id in next-action, no cookie, and the body [3]. What does the trusting version do?

                Choose an answer to see the explanation.

              2. Question 2 of 8Where can someone find the id of the shop's cancel action?

                Choose an answer to see the explanation.

              3. Question 3 of 8This is the checked actions file. A form arrives with boxes set to -3. Which part stops it?

                Read the code, then predictTypeScript
                "use server";
                
                import { z } from "zod";
                import { cancelOrder, changeOrderBoxes, getCurrentUser } from "@/lib/dal";
                
                const boxesForm = z.object({
                  orderId: z.coerce.number().int().positive(),
                  boxes: z.coerce.number().int().min(1).max(5),
                });
                const orderId = z.number().int().positive();
                
                export async function changeBoxes(formData: FormData) {
                  const input = boxesForm.safeParse({ orderId: formData.get("orderId"), boxes: formData.get("boxes") });
                  if (!input.success) return;
                  await changeOrderBoxes(input.data.orderId, input.data.boxes);
                }
                
                export async function cancel(id: unknown) {
                  if (!(await getCurrentUser())) return { message: "Sign in first." };
                  const checked = orderId.safeParse(id);
                  if (!checked.success) return { message: "That isn't an order number." };
                  return { message: await cancelOrder(checked.data) };
                }
                

                Choose an answer to see the explanation.

              4. Question 4 of 8A page on evil.example posts the shop's contact form to shop.example. What happens?

                Choose an answer to see the explanation.

              5. Question 5 of 8A program posts the contact form's fields with the action's id and no Origin header at all. What does Next.js 16.4.3 do?

                Choose an answer to see the explanation.

              6. Question 6 of 8The order page's inline action uses orderId, which Next.js encrypts into the page. What does that encryption NOT do?

                Choose an answer to see the explanation.

              7. Question 7 of 8This is lib/rate-limit.ts. The shop runs on three servers, and each request goes to any one of them. How many tries can someone make for one email in 15 minutes?

                Read the code, then predictTypeScript
                import "server-only";
                
                // Tries per key, kept in this one server's memory. With more than one server, or a
                // serverless platform, each copy has its own Map: use a shared store, like Redis.
                const tries = new Map<string, number[]>();
                
                export function allowTry(key: string, limit: number, windowMs: number) {
                  const now = Date.now();
                  const recent = (tries.get(key) ?? []).filter((time) => now - time < windowMs);
                  if (recent.length >= limit) return false;
                  recent.push(now);
                  tries.set(key, recent);
                  return true;
                }
                

                Choose an answer to see the explanation.

              8. Question 8 of 8In production, an action throws Error("connect ECONNREFUSED 10.0.0.5:5432 … shop_admin"). What reaches the browser?

                Choose an answer to see the explanation.

              Key takeaways

              • A Server Action runs for any POST that names its id, from any program, with any arguments.
              • Inside every action: ask who is calling, check every argument with a schema, then let the data access layer decide.
              • Bound arguments and hidden fields are arguments like any other. Only values an inline action uses are encrypted.
              • Next.js compares Origin with the host, limits the body, makes ids from a secret, and hides thrown errors. None of it is authorization.
              • Behind a proxy, list the real site in allowedOrigins. Keep schemas' own limits whatever bodySizeLimit says.
              • Limit how often an action runs, with counts in a store every server shares.
              • Return plain messages; log the details. Delete actions nothing calls.

              Remember the one-liner.
              Treat every Server Action as a public endpoint: check who, check what, check how often, and say no more than the page needs.

              Read it in Next.js's own code

              The Origin check, the body size limit and the 409 for an unknown id are all in packages/next/src/server/app-render/action-handler.ts. How the body is decoded and how actions are found belongs to the Server Actions under the hood lesson.

              Coming next: Security headers and Content Security Policy: headers that tell the browser what the shop's pages may do, and a policy for which scripts may run.

              CompleteFrontend Clear concepts. Working examples.