React Server Components (RSC): The Hidden Data Leak Risk

Passing data blindly from Next.js Server Components to Client Components is causing severe API data leaks. Learn how to sanitize props.
React Server Components (RSC) in Next.js 14 and 15 changed the paradigm. You can now fetch data directly inside your React components without API routes. But this powerful capability has created an entirely new category of data leaks that AI coding assistants frequently fail to recognize.
How Next.js Serializes Server Component Data
To understand why this vulnerability exists, you need to understand what Next.js actually does when it renders a Server Component.
When Next.js renders a page on the server, it executes all Server Components, runs all database queries, and collects all the props being passed to Client Components. It then serializes this data into a special format called the RSC payload — a JSON-like structure embedded in the HTML response.
Open the source of any Next.js app in your browser and you'll find something like this near the bottom:
<script id="__NEXT_DATA__" type="application/json">
{
"props": {
"pageProps": {
"user": {
"id": "clz8abc123",
"name": "Alice",
"email": "alice@example.com",
"passwordHash": "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
"stripeCustomerId": "cus_Nffrx7",
"internalNotes": "Flagged for fraud review",
"isAdmin": false
}
}
}
}
</script>Every field you query from the database and pass as a prop to a Client Component ends up in this script tag. Anyone can see it by pressing Ctrl+U in their browser. No DevTools required.
In a Next.js 15 app using the App Router, the equivalent appears in the RSC payload format embedded differently, but the principle is identical: any serializable data passed from a Server Component to a Client Component is sent over the wire and embedded in the response.
The Five Ways RSC Can Leak Data
1. Prop Serialization (The Classic Leak)
// app/profile/page.tsx (Server Component)
export default async function ProfilePage() {
// Queries EVERYTHING from the database
const user = await db.user.findFirst({
where: { id: getUserId() }
});
// ❌ The ENTIRE user object — including passwordHash, internalNotes,
// stripeCustomerId — is serialized and sent to the browser
return <UserProfile user={user} />;
}// components/UserProfile.tsx (Client Component)
"use client"
export function UserProfile({ user }: { user: any }) {
// You only render the name — but ALL fields are in the HTML source
return <h1>Welcome, {user.name}</h1>;
}The component only uses user.name, but the entire object — including passwordHash — is embedded in the page.
2. Passing Full Prisma/Database Objects
ORMs like Prisma return objects with every column by default. Developers often pass the entire result without thinking about which fields are included:
// ❌ Prisma returns ALL columns: id, name, email, passwordHash,
// createdAt, updatedAt, stripeCustomerId, internalNotes, isDeleted...
const user = await prisma.user.findUnique({ where: { id } });
return <UserCard user={user} />;
// Versus safe Prisma query with explicit field selection
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
avatarUrl: true,
// passwordHash: false (excluded by default when you use select)
}
});3. API Routes Returning Raw DB Models
This isn't unique to RSC, but it's worth including: API routes that return full database objects to client fetches have the same problem. The fix is the same — use a select or DTO.
// ❌ Returns everything including sensitive fields
export async function GET(req: Request, { params }) {
const user = await db.user.findUnique({ where: { id: params.id } });
return Response.json(user);
}
// ✅ Returns only what the client needs
export async function GET(req: Request, { params }) {
const user = await db.user.findUnique({
where: { id: params.id },
select: { id: true, name: true, avatarUrl: true }
});
return Response.json(user);
}4. JSON.stringify() of Sensitive Objects in Metadata
Next.js metadata functions run on the server but their output is embedded in the HTML:
// app/user/[id]/page.tsx
export async function generateMetadata({ params }) {
const user = await getUserById(params.id);
return {
title: user.name,
// ❌ If user has sensitive fields, they could leak here
other: {
"user-data": JSON.stringify(user) // leaks everything
}
};
}Keep metadata minimal — only include what's needed for social previews and SEO.
5. Error Messages Containing Stack Traces
Next.js in production mode suppresses error details, but development patterns sometimes leak into production:
// ❌ Error handling that might expose internal details
try {
const result = await riskyOperation();
} catch (error) {
// Don't pass error.message or error.stack to the client
return Response.json({
error: error.message, // might contain SQL queries, file paths, or secrets
stack: error.stack // definitely contains file paths and internal structure
}, { status: 500 });
}
// ✅ Safe error response
} catch (error) {
console.error("Operation failed:", error); // log server-side only
return Response.json({ error: "Operation failed" }, { status: 500 });
}Real Example: What a Leaked Object Looks Like
Here's what appears in the browser HTML source when a full Prisma user object is passed to a Client Component:
<script>
self.__next_f.push([1, "...{\"id\":\"clz8n3k6x0000abcdef\",\"name\":\"Alice Smith\",
\"email\":\"alice@acme.com\",\"passwordHash\":\"$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy\",
\"createdAt\":\"2026-01-15T10:30:00.000Z\",\"stripeCustomerId\":\"cus_Nffrx7QmKx7\",
\"stripeSubscriptionId\":\"sub_1MowQVLkdIwHu7ixeRlqHVzs\",\"role\":\"user\",
\"internalNotes\":\"High-value customer, waived first month\",\"isAdmin\":false,
\"resetToken\":\"8f14e45fceea167a5a36dedd4bea2543\"}..."])
</script>Password hash, Stripe IDs, internal business notes, account reset tokens — all visible to any user who opens View Source. In a multi-user app, this typically means user A can see user B's sensitive data by looking at user B's public profile page.
The Correct Mental Model: RSC as a View Layer
The right way to think about Server Components is that they're the view layer — they should receive only the data needed for rendering, pre-processed and sanitized.
The data fetching layer should return DTOs (Data Transfer Objects) — explicit, intentional subsets of your data model:
Database → Data Access Layer (prisma/queries.ts)
→ DTO mapping (only safe fields)
→ Server Component
→ Client Component props (already safe)
Don't let database models flow through your rendering layer unchecked.
The DTO Pattern
Never pass raw database models across the Server-Client boundary. Always map them to a safe payload:
// lib/dto/user.ts — define what's safe to send
export type UserPublicDTO = {
id: string;
name: string;
avatarUrl: string | null;
};
export type UserProfileDTO = UserPublicDTO & {
email: string;
createdAt: Date;
};
export function toUserPublicDTO(user: User): UserPublicDTO {
return {
id: user.id,
name: user.name,
avatarUrl: user.avatarUrl,
// All other fields are explicitly excluded
};
}
// app/users/[id]/page.tsx
import { toUserPublicDTO } from "@/lib/dto/user";
export default async function UserPage({ params }) {
const user = await prisma.user.findUnique({ where: { id: params.id } });
if (!user) notFound();
// ✅ Only safe fields are passed to the Client Component
return <UserProfile user={toUserPublicDTO(user)} />;
}Zod-Based DTO Pattern
Using Zod schemas as DTOs gives you both type safety and runtime validation:
// lib/schemas/user.ts
import { z } from "zod";
export const UserPublicSchema = z.object({
id: z.string(),
name: z.string(),
avatarUrl: z.string().nullable(),
});
export const UserProfileSchema = UserPublicSchema.extend({
email: z.string().email(),
memberSince: z.date(),
});
export type UserPublic = z.infer<typeof UserPublicSchema>;
export type UserProfile = z.infer<typeof UserProfileSchema>;
// Usage in a Server Component
export default async function UserPage({ params }) {
const rawUser = await prisma.user.findUnique({ where: { id: params.id } });
if (!rawUser) notFound();
// Zod strips any fields not in the schema (no passwordHash, stripeCustomerId, etc.)
const user = UserPublicSchema.parse(rawUser);
return <UserProfile user={user} />;
}The z.parse() call both validates and strips unknown fields. If someone adds a sensitive column to the database later, it won't leak to the client unless someone explicitly adds it to the Zod schema.
The taint API in React 19
React 19 introduces an experimental API for explicitly marking objects as sensitive:
import { experimental_taintObjectReference } from 'react';
async function getUserData(id: string) {
const user = await db.user.findUnique({ where: { id } });
// Mark the user object as tainted — React will throw if you try to pass it to a Client Component
experimental_taintObjectReference(
"Do not pass raw user objects to Client Components. Use a DTO.",
user
);
return user;
}If you accidentally pass a tainted object as a prop to a Client Component, React throws an error in development. This is defense in depth — it doesn't replace DTOs, but it provides a safety net when someone forgets to apply one.
Prisma-Specific Patterns
When using Prisma, use select to exclude sensitive fields at the query level:
// lib/queries/user.ts
// Returns only public fields — safe to pass to Client Components
export async function getUserPublic(id: string) {
return prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
avatarUrl: true,
createdAt: true,
}
});
}
// Returns extended fields — only for use on the server
export async function getUserFull(id: string) {
return prisma.user.findUnique({
where: { id },
// No select — returns everything, including sensitive fields
// This function should ONLY be called in server-only contexts
});
}Combining this with the server-only package:
// lib/queries/user-private.ts
import "server-only"; // Throws at build time if imported in a Client Component
export async function getUserFull(id: string) {
return prisma.user.findUnique({ where: { id } });
}How to Audit Your App for RSC Leaks
Step 1: Check the HTML source
Open your app in a browser, navigate to a page that shows user data, and press Ctrl+U (View Source). Search for field names like passwordHash, stripeCustomerId, or apiKey. If you find them, you have a leak.
Step 2: Inspect the RSC payload
In Chrome DevTools → Network tab → filter by type "fetch" or search for __rsc. Look at the response bodies for these requests — they contain the serialized RSC payload. Search for sensitive field names.
Step 3: Check Client Component prop types
Look for Client Components that receive user: any or data: any in their props. These are red flags — the lack of a specific type suggests the developer didn't think carefully about which fields are included.
# Grep for "use client" files that accept broad types
grep -rn '"use client"' ./components --include="*.tsx" -l | \
xargs grep -l "user: any\|data: any\|props: any"Step 4: Check for prisma queries without select
# Find Prisma queries that return all fields (no select clause)
grep -rn "findUnique\|findFirst\|findMany" ./app --include="*.tsx" --include="*.ts" | \
grep -v "select:"For related security concerns about authorization in Next.js, see Why NextAuth Doesn't Guarantee API Security and Top 5 Vulnerabilities in AI-Generated Apps.
FAQ
Does server-only prevent all RSC data leaks?
No. server-only prevents a Client Component from importing a module marked as server-only. It doesn't prevent a Server Component from passing sensitive data as props to a Client Component. You need both: server-only for modules that should never be imported client-side, and DTOs for controlling what data crosses the boundary.
Does this apply to the Pages Router?
The Pages Router has the same issue with getServerSideProps and getStaticProps — their return values are serialized into __NEXT_DATA__ in the HTML. The fix is the same: return only the fields you need, not full database objects.
My Client Component needs a lot of fields — is a DTO still worth it?
Yes. The DTO pattern isn't about minimizing the number of fields — it's about making the decision explicit. If you need 20 fields, define a DTO with those 20 fields. What you're preventing is accidentally including a 21st field (like passwordHash) because you used SELECT * instead of enumerating fields.
How does VibeShield detect RSC data leaks?
VibeShield fetches your pages and analyzes the RSC payload and __NEXT_DATA__ script tags for patterns that match sensitive data — password hashes, API key formats, Stripe IDs, and other high-entropy strings. It also checks for field names that commonly indicate sensitive data (passwordHash, apiKey, secretToken, internalNote).
If you are unsure whether your Server Components are silently leaking internal metadata to the browser, run an automated scan. VibeShield analyzes the JSON payloads actively shipped in your Next.js initial render tree to hunt for exposed credentials and hashes.
Free security scan
Test your app for these vulnerabilities
VibeShield automatically scans for everything covered in this article and more — 18 security checks in under 3 minutes.
Scan your app free