Topic 21.5
Securing Server Components and Actions
In one line
A Server Function ("use server") is a public HTTP endpoint that anyone can call with any arguments, so each one must validate its input, check who is calling and whether they may touch this record, and return only what the UI needs; and Server Components must pass Client Components only the fields they need, with the taint APIs as an experimental safety net.
Think of it like this
A restaurant kitchen with a serving hatch. Inside the kitchen (the server) there are knives, the safe and the recipe book. Waiters pass plates out through the hatch (to the client), and order slips come in through it. Two rules keep it safe: only finished plates go out, never the safe's key that happened to be on the tray; and every order slip is checked (is this a real table? is this dish on the menu?) because anyone can push a slip through the hatch, not only your waiters.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Server boundary
- The line between code that only runs on the server and code that also runs in the browser.
- Serialise
- Turn objects into text or bytes so they can be sent over the network.
- RSC payload
- The data stream a server sends with the Server Component tree's output and the props of Client Components.
- Server Function
- An async function marked
"use server"that the client can call; it runs on the server as an endpoint. - Authentication
- Checking who the caller is (their session).
- Authorisation
- Checking whether this caller may do this action on this record.
- IDOR
- Insecure direct object reference: a user changes an id in a request and reaches someone else's data.
- Schema validation
- Checking data against a description of the expected shape and rules (types, lengths, formats).
- DTO
- Data transfer object: a plain object containing only the fields a caller is allowed to receive.
- Taint
- React's experimental marking of an object or value so it throws if passed to the client.
Step by step
01See the boundary
Draw the line: everything on the server side may touch secrets; everything that crosses (props to Client Components, Server Function arguments and return values) is visible to and controllable by the user.
02Write the four steps into every Server Function
Authenticate from the cookie, validate with a schema, authorise against the record, return the minimum. Keep the steps in the Data Access Layer so actions stay thin and the checks can't be forgotten.
"use server";
import * as z from "zod";
import { revalidatePath } from "next/cache";
import { getSession } from "@/lib/auth";
import { db } from "@/lib/db";
const RenamePost = z.object({
postId: z.string().uuid(),
title: z.string().trim().min(1).max(120),
});
export async function renamePost(prevState: unknown, formData: FormData) {
const session = await getSession(); // 1. who is calling?
if (!session) return { error: "Please log in." };
const parsed = RenamePost.safeParse({ // 2. validate everything
postId: formData.get("postId"),
title: formData.get("title"),
});
if (!parsed.success) return { errors: z.flattenError(parsed.error).fieldErrors };
const post = await db.post.findUnique({ where: { id: parsed.data.postId } });
if (!post || post.authorId !== session.userId) { // 3. may they touch THIS post?
return { error: "Not found." };
}
await db.post.update({ where: { id: post.id }, data: { title: parsed.data.title } });
revalidatePath("/posts/" + post.id);
return { ok: true }; // 4. return the minimum
}03Pass DTOs, not rows
Fetch through a server-only module that returns exactly what the screen needs. Return null for fields the viewer may not see rather than hoping the component hides them.
import "server-only";
import { getSession } from "@/lib/auth";
import { db } from "@/lib/db";
export async function getProfileDTO(userId: string) {
const viewer = await getSession();
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) return null;
return {
name: user.name,
avatar: user.avatar,
email: viewer?.userId === user.id ? user.email : null, // only you see your email
};
}04Add the taint safety net (experimental)
In Next.js, enable experimental.taint and taint whole records and secret values where they're loaded. If someone later passes them to a Client Component, rendering throws with your message instead of leaking.
import "server-only";
import { experimental_taintObjectReference, experimental_taintUniqueValue } from "react";
export async function getUserRow(id: string) {
const user = await db.user.findUnique({ where: { id } });
experimental_taintObjectReference("Pass a DTO, not the user row, to client components", user);
experimental_taintUniqueValue("Never send the reset token to the client", user, user.resetToken);
return user;
}
// next.config.ts
// const nextConfig = { experimental: { taint: true } };05Audit like an attacker
For every "use server" export: call it in your head with a stranger's session, someone else's ids, empty values, huge strings and wrong types. For every "use client" component: look at its props type; anything broader than what it renders is a leak waiting to happen. Bracketed route folders ([id]) are user input too.
Try it yourself
- 1
Remove the authorisation step
In the first example, delete the
if (post.authorId !== session.userId)line. Predict the third log line. ({ ok: true, title: 'Hacked' }: logged-in Asha just renamed Ravi's post. That's an IDOR.) - 2
Leak one more field
In the DTO example, add
email: user.emailtotoPublicProfile. Predict the second line and the byte count. Then ask: should every visitor of this profile see it? (Usually only its owner: decide in the DTO, as the walkthrough'sgetProfileDTOdoes.)
Code & diagrams
The preview has no server, so `renamePost` is a plain async function standing in for a `"use server"` one, with the same `(prevState, formData)` signature `useActionState` uses. The direct calls show why the browser's `required` and `maxLength` mean nothing to an endpoint: each attack is stopped only by a check **inside** the function. Note it says `Not found.` for a missing post; many apps also return that for other people's posts, so attackers can't learn which ids exist.
Preview
Console
Nothing logged yet.Expected console output
huge title: { error: 'Title must be 1-60 characters.' }
someone else's post: { error: 'Not found.' }
someone else's post: { error: 'Not your post.' }
missing field: { error: 'Bad input.' }
normal call: { ok: true, title: 'Hello again' }The card looks identical either way, which is exactly why this leak is easy to miss: the screen shows a name, while the payload carried the password hash, reset token and internal notes to every visitor. The DTO is also a third of the size.
Preview
Console
Nothing logged yet.Expected console output
sent with the whole row: id, name, avatar, email, passwordHash, resetToken, isAdmin, internalNotes
sent with the DTO: id, name, avatar
bytes: 189 vs 51$ cat check.mjs
import * as z from "zod";
const RenamePost = z.object({
postId: z.string().uuid(),
title: z.string().trim().min(1).max(120),
});
const result = RenamePost.safeParse({ postId: "42", title: "" });
console.log(result.success);
console.log(z.flattenError(result.error).fieldErrors);
$ node check.mjs
false
{
postId: [ 'Invalid UUID' ],
title: [ 'Too small: expected string to have >=1 characters' ]
}// app/admin/page.tsx
export default async function AdminPage() {
const session = await auth();
if (!session?.user?.isAdmin) redirect("/login"); // controls what is RENDERED
return (
<form
action={async () => {
"use server";
// BAD without this: the action is its own POST endpoint.
const session = await auth();
if (!session?.user?.isAdmin) throw new Error("Unauthorized");
await db.record.deleteMany();
}}
>
<button>Delete records</button>
</form>
);
}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Trust the user id from the client
Write export async function deleteAccount(userId) { "use server"; await db.user.delete({ where: { id: userId } }); } and call it as deleteAccount(currentUser.id) from a button.
Break #2
Pass the whole row to a Client Component
In a Server Component, const user = await db.user.findUnique(...) then <ProfileCard user={user} /> where ProfileCard is "use client".
Myth vs fact
Myth
If no button calls a Server Function, nobody can call it.
Fact
Any exported, used Server Function has an endpoint. Next.js removes unused ones from the client and makes ids hard to guess, but you must still treat every action as reachable by a direct POST.
Myth
TypeScript types validate Server Function arguments.
Fact
Types are erased at runtime. A caller can send any value. Validate with code (a schema) inside the function.
Myth
The taint APIs stop all leaks.
Fact
They are experimental, track only exact references and values (copies and derived strings escape), and are a safety net. DTOs and narrow props are the real defence.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
React's own docs warn that Server Function arguments are fully client-controlled and that closures are sent to the client: frameworks must encrypt captured values (Next.js does, with a per-build key you can pin with
NEXT_SERVER_ACTIONS_ENCRYPTION_KEYwhen self-hosting several instances). Even encrypted, don't capture secrets in closures. - ▸
Functions and class instances can't be passed as props to Client Components (React throws), which is why the Next.js docs suggest returning class instances from auth helpers: an accidental
<Client user={currentUser} />fails instead of leaking. - ▸
Server Function return values go through the same serialiser as props, so a returned database row leaks exactly like a prop. Errors are different: in production, Next.js replaces thrown error messages from the server with a generic message and a
digest, so don't rely on error text for UI; return{ error }objects for expected failures. - ▸
import "server-only"fails the build if a client module imports the file; its mirror"client-only"exists too. They protect against environment poisoning: server code (andprocess.envsecrets) accidentally bundled for the browser.
Remember this
- 1
With React Server Components (RSC), one component tree spans two places. Server Components run only on the server, so they can read the database and secrets. Client Components (files marked
"use client") run in the browser too. The line between them is the server/client boundary: props passed from a Server Component to a Client Component are serialised (turned into a stream of data called the RSC payload) and sent to the browser, where anyone can read them in the Network tab. Topics 20.2 and 20.3 cover how Server Components and Server Functions work; this topic covers what crosses the line. - 2
Server Functions are async functions marked with
"use server". React and the framework turn each one into an endpoint: the client gets a reference (an id), and calling it sends a POST request with the arguments. That means a Server Function is reachable by anyone who can send a POST, with any arguments they like, whether or not your UI shows a button for it. The form'srequiredandmaxLength, a hidden button, or a page-level login check don't protect it. Next.js's docs say it plainly: a page's auth check doesn't extend to the Server Functions defined in it; re-verify inside each one. - 3
Every Server Function does four things, in this order: (1) Authenticate: read the session from the cookie on the server (never trust a
userIdargument). (2) Validate the input: every argument is untrusted text or data; check types, lengths, formats, ideally with a schema library such as Zod (Schema.safeParse(data)). TypeScript types are erased at runtime and don't validate anything. (3) Authorise: is this user allowed to do this to this record? Checking only that someone is logged in leaves an IDOR (insecure direct object reference) hole: user A changespostIdand edits user B's post. (4) Return the minimum: the return value is serialised to the client too, so return{ ok: true }or the few fields the UI shows, not the database row. Also rate-limit expensive actions. - 4
Don't leak data to Client Components. The easy mistake is
<ProfileCard user={user} />whereuseris the whole database row: the password hash, email and internal notes are now in the page's payload, even thoughProfileCardonly shows the name. The fix is a DTO (data transfer object: a plain object with only the allowed fields) made in a Data Access Layer: a server-only module (import "server-only"makes importing it from client code a build error) that does the authorisation and returns safe DTOs. Next.js recommends this for new projects. Type Client Component props narrowly ({ name: string; avatar: string }) so passing a whole row is a type error. - 5
Taint APIs are React's experimental safety net.
experimental_taintObjectReference(message, object)marks an object so that passing it to a Client Component throws an error with your message;experimental_taintUniqueValue(message, lifetimeObject, value)does the same for a specific string, bigint or typed array (such as an API key), for as long as the lifetime object is alive. They exist only in React's experimental channel, not in stable React 19.2 (the stablereactpackage doesn't export them); in Next.js you turn them on withexperimental: { taint: true }, which switches the App Router to that channel. They track references and exact values: a copy ({ ...user }) or a derived string ("key:" + key) is not tainted. Next.js warns not to rely on them alone: DTOs are the real fix. - 6
Framework protections help but don't replace your checks. Next.js gives Server Functions encrypted, non-guessable ids that change between builds, drops unused ones from the client bundle, only accepts POST, and compares the
Originheader with theHost(configurable withserverActions.allowedOrigins) to block CSRF. Values a Server Function closes over (variables from the component it was defined in) are encrypted before travelling through the client. All of that is defence in depth; Next.js still tells you to treat every action as a direct POST and check auth inside it.
Explain it without notes
Why must every Server Function check authentication and authorisation itself?
What are the four steps every Server Function should take, and why that order?
How can a Server Component leak private data, and what is the recommended fix?
What do experimental_taintObjectReference and experimental_taintUniqueValue do, and what are their limits?
Practice
Write a deleteComment(commentId) Server Function that is safe to expose.
Write a narrowly typed Client Component AuthorBadge and the Server Component code that passes it data from a user row.
List what you'd check when auditing a file that starts with "use server".
Trade-offs
- ↔
Validating with a schema library adds a dependency and some bundle size if shared with the client, but gives one source of truth for client hints and server enforcement; hand-written checks are lighter but drift.
- ↔
A strict Data Access Layer adds a layer of code between components and the database, which feels slow in small apps; it pays off as soon as more than one person writes queries, because auth checks live in one auditable place.
Done when you can
I treat every Server Function as a public POST endpoint with untrusted arguments.
I authenticate from the server session, validate with a schema, authorise per record and return the minimum.
I can explain IDOR and how to prevent it.
I pass DTOs, not database rows, to Client Components, from a
server-onlyData Access Layer.I can describe the experimental taint APIs and their limits.