Command Palette

Search for a command to run...

Hectal
PHASE 21Advanced ~33 min· topic 5 of 6

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.

See the boundarydiagram
Rendering diagram…

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.

app/posts/actions.tswhole filetsx
"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.

data/profile.tswhole filetsx
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.

data/user.tswhole filetsx
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. 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. 2

    Leak one more field

    In the DTO example, add email: user.email to toPublicProfile. 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's getProfileDTO does.)

Code & diagrams

A Server Function is an endpoint anyone can call (simulated) React 19+ New tab

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

Press Run to see the page.

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' }
What crosses the boundary: a row versus a DTO New tab

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

Press Run to see the page.

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
Schema validation with Zod 4 (real output)bash
$ 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' ]
}
The page check doesn't protect the action (Next.js)tsx
// 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.

terminal
$ Any HTTP client
── what you'll see ──
A POST to the action with another user's id deletes that user's account. Nothing in the UI changed, and nothing in the server logs looks unusual.

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".

terminal
$ DevTools > Network (the page's RSC payload)
── what you'll see ──
The response contains "passwordHash":"$argon2id$...", "resetToken":"rt_91f3" and every other column, although the card only shows the name.

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_KEY when 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 (and process.env secrets) accidentally bundled for the browser.

Remember this

  1. 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. 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's required and maxLength, 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. 3

    Every Server Function does four things, in this order: (1) Authenticate: read the session from the cookie on the server (never trust a userId argument). (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 changes postId and 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. 4

    Don't leak data to Client Components. The easy mistake is <ProfileCard user={user} /> where user is the whole database row: the password hash, email and internal notes are now in the page's payload, even though ProfileCard only 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. 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 stable react package doesn't export them); in Next.js you turn them on with experimental: { 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. 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 Origin header with the Host (configurable with serverActions.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

01

Why must every Server Function check authentication and authorisation itself?

02

What are the four steps every Server Function should take, and why that order?

03

How can a Server Component leak private data, and what is the recommended fix?

04

What do experimental_taintObjectReference and experimental_taintUniqueValue do, and what are their limits?

Practice

01

Write a deleteComment(commentId) Server Function that is safe to expose.

02

Write a narrowly typed Client Component AuthorBadge and the Server Component code that passes it data from a user row.

03

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-only Data Access Layer.

  • I can describe the experimental taint APIs and their limits.