Type something to search...
Next.js Data security in Next.js

Next.js Data security in Next.js

For most of the framework's history, the security model of a React app was easy to reason about, whatever code ran in the browser was untrusted by definition, and everything else, an API server, a backend, a serverless function, was trusted. You drew one line, and you drew it once.

React Server Components erase that line. In the App Router, a component can read a database row, decrypt a session cookie, and call an internal API, all in a function that looks exactly like the component sitting three files away that renders a button. Nothing in the syntax warns you that one of these runs in a trusted environment and the other doesn't. That's the whole appeal of Server Components, less plumbing, colocated logic, no separate API layer to maintain, but it also means the compiler can't save you from yourself the way it could when "server code" and "client code" lived in physically separate folders with a REST call between them. This guide is about the mental model you need to keep that boundary intact, and the specific Next.js features built to catch you when you don't.

Why the old assumptions stopped working

In a classic client-rendered React app, or even in the Pages Router with getServerSideProps, there was a clear seam: your page component received props, and those props were already the "public" shape of the data, whatever your data-fetching function decided to return. You had exactly one place to audit.

In the App Router, that seam can appear anywhere, or nowhere. A Server Component can fetch an entire database row and pass the whole object as a prop to a child. If that child is a Client Component, every field on that object gets serialized into the HTML payload and shipped to the browser, whether the UI uses it or not. There's no framework-level warning for this, because from Next.js's point of view, you asked for that data to cross the boundary. It doesn't know your user object has a passwordHash field you forgot about.

This is the shift worth internalizing: security in the App Router isn't about keeping secrets out of "the client folder." It's about being deliberate every time data crosses from a Server Component into a Client Component, because that crossing is now a normal, frequent, syntactically invisible event.

Three ways to fetch data, and why picking one matters

Next.js's own guidance settles on three broad patterns, and the docs are refreshingly direct about which one you should be using depending on where your project is:

External HTTP APIs, for teams migrating an existing app or organization that already has a backend with its own auth and validation.

A Data Access Layer (DAL), the recommended default for anything new.

Component-level data access, fine for a weekend prototype, risky for anything else.

The mistake I see most often isn't picking the wrong one, it's mixing all three across a codebase. Once you've got some pages hitting a REST API, others calling a DAL function, and others running raw SQL inside a Server Component, nobody, including a security auditor six months from now, can tell at a glance what the actual data-access contract of a given page is. Pick one pattern early and be consistent, even if it means writing a thin DAL wrapper around calls you'd otherwise inline.

External HTTP APIs: treat your own backend as untrusted

If you already have a REST or GraphQL API, you don't need to change anything structural, just call it from Server Components the same way you would from a Client Component, forwarding whatever auth token identifies the request:

// app/page.tsx
import { cookies } from "next/headers";

export default async function Page() {
  const cookieStore = await cookies();
  const token = cookieStore.get("AUTH_TOKEN")?.value;

  const res = await fetch("https://api.example.com/profile", {
    headers: {
      Cookie: `AUTH_TOKEN=${token}`,
    },
  });

  const profile = await res.json();
  // ...
}

This is a "Zero Trust" approach in the sense that Next.js isn't the thing enforcing authorization, your API is, exactly as it did before Server Components existed. The advantage is that you don't have to re-architect anything. The cost is that you're now making a network round trip from inside a Server Component to another service, which is fine, but worth remembering when you're debugging latency, since it's easy to forget a Server Component isn't "free" just because it's not shipped to the browser.

The Data Access Layer: the pattern to actually adopt

For a new project, the recommendation is to build a dedicated, server-only module, commonly called a Data Access Layer, that owns three responsibilities: it only ever runs on the server, it performs authorization checks itself rather than trusting the caller, and it returns a minimal, sanitized shape rather than a raw database row.

Here's what that looks like in practice. First, a cached helper for identifying the current user:

// data/auth.ts
import { cache } from "react";
import { cookies } from "next/headers";

// `cache` means every call within the same request reuses this result,
// so you can call getCurrentUser() from a dozen places without a dozen
// cookie reads or token decryptions.
export const getCurrentUser = cache(async () => {
  const cookieStore = await cookies();
  const token = cookieStore.get("AUTH_TOKEN");
  const decodedToken = await decryptAndValidate(token);
  return new User(decodedToken.id);
});

Then the actual data-access function, which decides what's safe to return based on who's asking:

// data/user-dto.tsx
import "server-only";
import { getCurrentUser } from "./auth";

function canSeePhoneNumber(viewer: User, team: string) {
  return viewer.isAdmin || team === viewer.team;
}

export async function getProfileDTO(slug: string) {
  const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`;
  const userData = rows[0];
  const currentUser = await getCurrentUser();

  // Return only what this specific view needs — not the row.
  return {
    username: userData.username,
    phonenumber: canSeePhoneNumber(currentUser, userData.team)
      ? userData.phonenumber
      : null,
  };
}

The page just calls the DAL and trusts what comes back:

// app/page.tsx
import { getProfileDTO } from "../../data/user-dto";

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const profile = await getProfileDTO(slug);
  return <Profile profile={profile} />;
}

Notice the term Data Transfer Object here, borrowed from backend architecture generally, not something Next.js invented. The idea is that getProfileDTO never returns "the user," it returns "the shape of a user that this particular page is allowed to render." That distinction sounds pedantic until you've been on a team where a <Profile user={user} /> component quietly grew a feature that displayed user.email, and nobody noticed that user had been the full database row all along.

One detail worth calling out explicitly because it's easy to miss: the "Good to know" callout in the docs specifies that secret keys should be read from process.env only inside the DAL, not scattered across route handlers and Server Components. If process.env.STRIPE_SECRET_KEY only ever appears in one file in your codebase, that file is trivially auditable. If it appears in twelve files because everyone found it more convenient to read the env var directly, you've lost that guarantee for no real benefit.

Component-level access: fine until it isn't

For a prototype, querying the database straight from a Server Component is the fastest path to something working. But it's also the fastest path to an accidental leak, because the failure mode is invisible in development. Here's the shape of the mistake:

// app/page.tsx — leaks every column in `userData` to the client
import Profile from "./components/profile";

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`;
  const userData = rows[0];
  return <Profile user={userData} />;
}
// app/ui/profile.tsx
"use client";

export default function Profile({ user }: { user: User }) {
  return <h1>{user.name}</h1>;
}

Profile only renders user.name. But because user crosses into a Client Component, every field on it, passwordHash, internal flags, whatever else lives on that row, gets serialized into the page payload and is sitting in the browser's DOM/RSC stream regardless of whether the component ever reads it. Anyone can open dev tools and find it. The fix isn't a framework feature, it's discipline: shape the data before it crosses the boundary.

// data/user.ts
export async function getUser(slug: string) {
  const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`;
  const user = rows[0];
  return { name: user.name }; // only what the UI needs
}

This is the same principle as the DAL, just applied inline. The lesson generalizes well beyond databases: whenever you're about to hand an object to a Client Component, ask what the narrowest shape is that satisfies the props the component actually destructures, and construct that shape explicitly rather than passing the object you happen to already have.

The server/client boundary, mechanically

It helps to understand what's actually happening at build time, because "Server Components can't leak to the client" is a rule, not a law of physics, and rules have edge cases.

Server and Client Components run in separate module graphs. On the first request, both execute on the server to produce the initial HTML, but they're compiled and bundled as if they were entirely separate programs. A Server Component's module can import fs, hit a database driver, read process.env.SECRET_KEY, none of that code, or its imports, ever gets bundled for the browser. A Client Component's module, on the other hand, is bundled for the browser by definition, so it must be written as if it's already running there, no privileged APIs, no assumption of trusted execution.

The isolation is structural, but the data that crosses the boundary, function arguments passed as props from a Server Component into a Client Component, is serialized as part of the render output. That serialization step is the actual attack surface. The module boundary keeps your database credentials out of the bundle; it does nothing to stop you from serializing a database row's contents into props.

Tainting: a safety net, not a strategy

React ships two APIs specifically for catching this class of mistake at runtime: experimental_taintObjectReference for marking whole objects as unsafe to pass to the client, and experimental_taintUniqueValue for marking individual sensitive values, like a raw API key string. You opt in via next.config.js:

// next.config.js
module.exports = {
  experimental: {
    taint: true,
  },
};

Once enabled, if a tainted object or value gets passed toward a Client Component, React throws instead of silently serializing it. It's a genuinely useful last line of defense, especially on a team where junior developers are shipping features against a codebase whose DAL you didn't personally write. But it's worth being honest about its limits: tainting only catches values you remembered to taint. It's not a scanner that finds every secret in your app; it's closer to a assertion you add at the point where you first fetch sensitive data, saying "this specific object should never leave the server." Treat it as a backstop for mistakes in an otherwise-sanitized DAL, not a substitute for sanitizing in the first place.

Two smaller but important details: functions and class instances can't be passed to Client Components at all, React blocks that automatically, and Next.js only ever exposes environment variables to the client if they're explicitly prefixed with NEXT_PUBLIC_. Everything else in process.env is server-only by default. That default is good, but it's also why the earlier advice about confining process.env reads to your DAL matters, the framework protects the variable, not the value once you've read it into a JavaScript object and handed that object to a component.

Making server-only code impossible to bundle for the client

Tainting protects values. The server-only package protects entire modules, by making it a hard build error, not a lint warning, to import a server-only file from anything that could end up in the client bundle.

npm install server-only
// lib/data.ts
import "server-only";

export async function getInternalConfig() {
  // ...
}

If any Client Component transitively imports lib/data.ts, next build fails. This is the tool I reach for whenever a module contains something I never want to discover was accidentally imported three refactors from now, a database client, an internal pricing calculation, anything that encodes business logic you don't want inspectable in a bundle analyzer. It costs one import line and catches an entire category of mistake at build time instead of in production.

One nuance worth knowing: Next.js implements server-only internally, so the actual npm package contents aren't what enforces the behavior, the import itself is a signal the Next.js compiler recognizes. You still install the package because some linters will flag it as an unused or extraneous dependency otherwise, but functionally, the enforcement is built into the framework.

Server Actions are public HTTP endpoints, whether you meant them to be or not

This is the section of the model most developers get wrong on their first App Router project, because Server Actions look like plain async functions, and it's easy to reason about them as if calling updateUser() from your UI is the only way updateUser() ever gets invoked.

It isn't. Once a function is exported with "use server", Next.js compiles it into a real network endpoint reachable by direct POST request. If your UI never calls it, doesn't matter, it's still callable by anyone who can guess or discover its ID, unless you've protected it yourself.

// app/actions.js
"use server";

// Used in the UI: gets a stable ID, remains callable.
export async function updateUserAction(formData) {}

// Never referenced anywhere: Next.js removes this from the client bundle
// entirely during `next build`, so it's not exposed as a public endpoint.
export async function deleteUserAction(formData) {}

That dead-code elimination is real and useful, an unreferenced action genuinely doesn't ship. But every action you do use is reachable, and Next.js's mitigation here is defense in depth rather than a substitute for your own checks: action IDs are encrypted and non-deterministic, and they rotate on a roughly two-week cycle tied to your build cache. That raises the bar for someone trying to enumerate or replay action IDs across builds, it does not mean you can skip writing authorization checks inside the action itself.

Validate everything the client hands you, every time

Any value that originates on the client, form fields, search params, headers, cookies the client could have tampered with before the request left the browser, has to be treated as untrusted input, full stop:

// BAD — trusting a query string to gate access to admin UI
export default async function Page({ searchParams }) {
  const isAdmin = (await searchParams).isAdmin;
  if (isAdmin === "true") {
    return <AdminPanel />;
  }
}

// GOOD — re-derive the answer from something the client can't forge
import { cookies } from "next/headers";
import { verifyAdmin } from "./auth";

export default async function Page() {
  const cookieStore = await cookies();
  const token = cookieStore.get("AUTH_TOKEN");
  const isAdmin = await verifyAdmin(token);
  return isAdmin ? <AdminPanel /> : null;
}

The searchParams version isn't a contrived example, it's the single most common vulnerability I've seen in App Router code reviews, because searchParams is so convenient to read that it's easy to forget it's just a query string anyone can edit.

A page-level check does not protect the actions defined inside that page

This is the one that catches experienced React developers, because it violates an intuition that's reasonable everywhere else in the framework: if you already redirected unauthorized users away from a page, surely the Server Actions defined on that page are safe too?

They aren't, because a Server Action is a separate network entry point from the page's render path. The redirect() at the top of your component controls what HTML gets sent back on a normal page load. It does nothing to stop someone from POSTing directly to the action's endpoint, bypassing your component's render logic entirely.

// app/admin/page.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";

export default async function AdminPage() {
  const session = await auth();
  if (!session?.user?.isAdmin) {
    redirect("/login");
  }

  return (
    <form
      action={async () => {
        "use server";
        // This check is not optional, even though the page above
        // already redirected non-admins away from this UI.
        const session = await auth();
        if (!session?.user?.isAdmin) {
          throw new Error("Unauthorized");
        }
        await db.record.deleteMany();
      }}
    >
      <button>Delete Records</button>
    </form>
  );
}

Beyond authentication (is there a logged-in user at all), you need authorization scoped to the specific resource being mutated, otherwise you've built a textbook Insecure Direct Object Reference vulnerability, where a logged-in user can act on someone else's data just by supplying a different ID:

// app/actions.ts
"use server";

export async function deletePost(postId: string) {
  const session = await auth();
  if (!session?.user) throw new Error("Unauthorized");

  const post = await db.post.findUnique({ where: { id: postId } });
  if (post.authorId !== session.user.id) {
    throw new Error("Forbidden"); // authenticated, but not authorized
  }

  await db.post.delete({ where: { id: postId } });
}

The pattern worth internalizing: "Unauthorized" means we don't know who you are; "Forbidden" means we know exactly who you are, and you're not allowed to do this. Conflating the two, or skipping the ownership check entirely because the user is logged in, is how IDOR bugs slip into production.

Push the same DAL discipline into your mutations

Everything said earlier about a Data Access Layer for reads applies equally to writes. Keep the actual authorization and database logic in a server-only module, and let your "use server" actions stay thin, delegating rather than containing the business logic:

// data/posts.ts
import "server-only";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";

export async function deletePost(postId: string) {
  const session = await auth();
  if (!session?.user) throw new Error("Unauthorized");

  const post = await db.post.findUnique({ where: { id: postId } });
  if (post.authorId !== session.user.id) throw new Error("Forbidden");

  await db.post.delete({ where: { id: postId } });
}
// app/actions.ts
"use server";
import { deletePost } from "@/data/posts";
import { revalidatePath } from "next/cache";

export async function deletePostAction(postId: string) {
  await deletePost(postId); // auth + authz live in the DAL, not here
  revalidatePath("/posts");
}

This split pays off the first time you need to call deletePost from more than one action, a REST-style admin tool, a background job, a test, without duplicating the authorization check or, worse, having it drift out of sync between copies.

Return only what the client needs, not the row you happened to fetch

The same DTO discipline from the reads section applies to what an action sends back:

// BAD — the full ORM record, whatever fields that happens to include
export async function updateUser(data: FormData) {
  const session = await auth();
  if (!session?.user) throw new Error("Unauthorized");
  return db.user.update({
    where: { id: session.user.id },
    data: { name: data.get("name") as string },
  });
}

// GOOD — an explicit, minimal shape
export async function updateUserSafe(data: FormData) {
  const session = await auth();
  if (!session?.user) throw new Error("Unauthorized");
  await db.user.update({
    where: { id: session.user.id },
    data: { name: data.get("name") as string },
  });
  return { success: true };
}

An action's return value is serialized straight to the client, same as a prop. It gets no special treatment just because it came back from a mutation instead of a query.

Rate limit anything expensive

If an action sends an email, calls a paid third-party API, or does anything else that costs money or resources per call, put a rate limiter in front of it. This isn't Next.js-specific, but it's easy to forget precisely because Server Actions feel like calling a local function rather than hitting an endpoint, which is exactly the illusion this whole guide is about not falling for.

Closures capture more than you think, and Next.js encrypts them for you

A Server Action defined inline inside a component closes over whatever's in scope:

export default async function Page() {
  const publishVersion = await getLatestVersion();

  async function publish() {
    "use server";
    if (publishVersion !== (await getLatestVersion())) {
      throw new Error("The version has changed since pressing publish");
    }
    // ...
  }

  return (
    <form>
      <button formAction={publish}>Publish</button>
    </form>
  );
}

publishVersion is captured at render time so the action can compare "the version I saw when I rendered this button" against "the version that exists right now." To make that work, the closed-over value has to travel to the client and back, which means it's serialized into the page and sent back up on submission. Next.js encrypts these closed-over values automatically, using a key generated fresh for each build, but treat that as a mitigation, not a guarantee, don't rely on closure encryption as your only defense for a genuinely sensitive value. If something must never reach the client even in encrypted form, keep it out of the closure and re-fetch it inside the action instead.

If you self-host across multiple server instances, each instance normally generates its own encryption key at build time, which breaks actions invoked across instances with mismatched keys. Pin it with NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, a base64-encoded 16, 24, or 32-byte value:

openssl rand -base64 32

CSRF protection is mostly automatic, but know the boundary

Server Actions only accept POST, which combined with SameSite cookies (the modern browser default) blocks most CSRF vectors outright. Next.js adds a second check on top: it compares the request's Origin header against its Host header (or X-Forwarded-Host), rejecting the request if they don't match. In effect, an action can only be invoked from the same host that serves the page.

That check becomes a problem, not a protection, if your architecture puts a reverse proxy or a separate API domain in front of your app, the origin genuinely won't match the host in that setup, and you'll get rejected requests that are actually legitimate. The fix is serverActions.allowedOrigins in next.config.js:

/** @type {import('next').NextConfig} */
module.exports = {
  experimental: {
    serverActions: {
      allowedOrigins: ["my-proxy.com", "*.my-proxy.com"],
    },
  },
};

Only add origins you actually control and trust, this option exists to widen a security check, so treat every entry as an explicit statement that this origin is allowed to invoke mutations on your behalf.

Don't trigger mutations as a side effect of rendering

Next.js actively blocks setting cookies or calling revalidatePath/revalidateTag from inside a render path, and for good reason, a GET request should never have side effects. If a page silently logged a user out because they happened to load a URL with ?logout=true, that's a mutation triggered by something as simple as a shared link or a crawler following a href.

// BAD — a GET request that mutates state
export default async function Page({ searchParams }) {
  if ((await searchParams).logout) {
    const cookieStore = await cookies();
    cookieStore.delete("AUTH_TOKEN");
  }
  return <UserProfile />;
}
// GOOD — the mutation lives behind an explicit POST via a Server Action
import { logout } from "./actions";

export default function Page() {
  return (
    <>
      <UserProfile />
      <form action={logout}>
        <button type="submit">Logout</button>
      </form>
    </>
  );
}

A short audit checklist worth keeping pinned somewhere

If you're reviewing an existing App Router codebase, whether your own or one you've inherited, these are the places worth spending disproportionate time:

  • Is there one Data Access Layer, or three half-formed ones? Grep for direct database imports and process.env reads outside your intended DAL module. Every hit is a place the pattern has leaked.
  • Every "use client" file — do its props ever carry a full object where the component only reads one or two fields? That's a leak waiting to happen the next time someone adds a new field to the underlying type.
  • Every "use server" file — is authentication checked inside the action, not just on the page that renders it? Is authorization (ownership, not just login status) checked separately? Are return values filtered?
  • Every bracketed dynamic segment, [slug], [id], is genuinely user-controlled input. Confirm it's validated, not just interpolated into a query.
  • proxy.ts and route.ts files carry outsized power, they see every request before your app logic does. These deserve the same scrutiny you'd give a hand-rolled auth middleware, because that's functionally what they are.

Common mistakes worth calling out explicitly

A few patterns I've watched trip up teams that were otherwise careful:

Assuming Server Components are automatically safe. They're safe from the browser reading their code. They say nothing about the data they choose to pass onward. The isolation is at the module level, not the data level.

Treating server-only and tainting as interchangeable. server-only stops a module from being bundled for the client at all. Tainting stops a specific value from being serialized once it's already in a Server Component's scope. You generally want both, they cover different failure modes.

Writing the authorization check once, at the page. As shown above, this is the single most common gap. A redirect that gates a page's render does not gate the Server Actions defined inside it.

Forgetting that action IDs, while encrypted and rotated, are still just IDs. Treat every exported Server Action as a public endpoint from day one, don't wait until a security review to add the auth check you assumed the framework was handling.

Returning ORM records straight from an action. It's the fastest way to write the happy path and the easiest way to accidentally ship an internal field to production.

Key Takeaways

ConcernMechanismWhere to apply it
Sanitizing what a Server Component passes to the clientData Access Layer returning DTOsEvery read that eventually reaches a "use client" component
Catching an accidental leak at runtimeexperimental_taintObjectReference / taintUniqueValueObjects and values you've identified as sensitive in your DAL
Preventing a module from ever bundling client-sideimport "server-only"Database clients, internal config, anything with business logic
Protecting a Server Action from anonymous callersAuth check inside the action, not just on the pageEvery exported "use server" function
Preventing IDOROwnership/authorization check per resourceAny action that mutates or deletes a specific record
Limiting what a mutation sends backExplicit return shape, not the ORM recordEvery Server Action's return statement
Cross-origin action calls behind a proxyserverActions.allowedOriginsMulti-domain or reverse-proxy deployments only
Consistent encryption across instancesNEXT_SERVER_ACTIONS_ENCRYPTION_KEYSelf-hosted, multi-server deployments

None of this is exotic. It's the same discipline backend developers have applied for years, validate input, check authorization per resource, minimize what you return, just relocated into a framework where the seam between "trusted" and "untrusted" code is no longer a folder boundary but a decision you make every time a prop crosses from a Server Component into a Client Component. Build the habit of asking "does this need to leave the server" every time you're about to pass something down, and most of this guide becomes muscle memory rather than a checklist.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper