Your React Server Components Are Leaking User Data Through Client Boundaries. Here's How.
Why the server/client boundary in React Server Components is a security minefield that most teams are crossing blindly - and how serialisation turns your careful access control into a data exposure incident waiting to happen.
The Production Leak Nobody Noticed for Three Weeks
It started with a Slack message from a security researcher: "Your user dashboard is exposing email addresses for users who have set their profiles to private." Attached was a screenshot of the browser DevTools Network tab showing a React Server Components payload - a JSON-like structure carrying server-rendered component props from the RSC streaming response.
Buried in that payload, clearly visible in plain text, were email addresses, internal user IDs, and subscription tier flags for every user whose profile appeared in a "suggested connections" widget - including users who had explicitly opted out of discoverability.
The bug wasn't in the database query. It wasn't in the authentication layer. It wasn't even in the component that rendered the data. The leak was in the serialisation boundary between the Server Component that fetched the data and the Client Component that displayed it.
The team had done everything right according to their mental model: the UI only showed public data, the database query filtered correctly, and the rendered HTML contained no sensitive fields. But React Server Components serialize the entire object you pass as props to client components - not just the fields you render. Every field that made it into that props object was traveling over the wire, sitting in the RSC payload, readable by anyone who opened DevTools.
"We weren't rendering the email addresses. We just forgot that passing them as props to a client component means serialising them into a payload that the browser can inspect."
How RSC Serialisation Actually Works (And Why It's Dangerous)
React Server Components represent a paradigm shift in how React applications fetch and render data. Server Components run exclusively on the server, can directly access databases and APIs, and never ship their code to the client. They return a serialised representation of the UI - essentially a JSON-like structure describing the component tree and the props flowing through it.
When a Server Component passes props to a Client Component (one marked with "use client"), React must serialise those props so they can be hydrated on the client. This serialisation happens automatically, invisibly, and completely.
What Gets Serialised
React serialises every enumerable property of the object you pass. That includes:
- Fields you render in the client component's JSX
- Fields you reference in event handlers but never display
- Fields you don't touch at all but happen to exist on the object
- Nested objects, arrays, and deeply structured data - traversed recursively
- Fields that were needed on the server for logic but are irrelevant to the client
If you fetch a user object from the database with 30 fields and pass the entire object to a client component that only renders the user's display name and avatar, all 30 fields travel to the client in the RSC payload. The client component never imports them, never references them - but they're there, visible in the Network tab, cached in the browser's memory, logged by any network monitoring tool in the request path.
// app/dashboard/page.tsx (Server Component) import { UserCard } from './UserCard'; import { db } from '@/lib/db'; export default async function DashboardPage() { const users = await db.query('SELECT * FROM users WHERE visible = true'); return ( <div> {users.map(user => ( <UserCard key={user.id} user={user} /> ))} </div> ); }
// app/dashboard/UserCard.tsx (Client Component) 'use client'; export function UserCard({ user }) { return ( <div className="card"> <img src={user.avatar} /> <h3>{user.displayName}</h3> <p>{user.bio}</p> </div> ); }
The UserCard component only reads three fields: avatar, displayName, and bio. But because the server passed the entire user object, every field from the database row - email, internal_id, stripe_customer_id, last_login_ip, account_flags - gets serialised into the RSC payload and sent to the browser.
Open DevTools while an RSC-based app loads. Look for a request to the page route with a header like RSC: 1 or a query param like _rsc=xxxxx. The response body is a line-delimited JSON structure. Each line corresponds to a chunk of the component tree. Props passed to client components appear in plain text within these chunks.
This isn't a bug. This is how RSC works. The client needs those props to hydrate the component. The security issue arises when developers treat the client boundary as an access control boundary - it isn't.
The Four Most Common Leak Patterns
1. The "SELECT *" Antipattern
Fetching entire rows from the database and passing the full object to client components. This is endemic in codebases that migrated from traditional React (where over-fetching on the server was less directly dangerous because it was obfuscated by an API layer). In RSC, there's no API layer - the Server Component is the API, and whatever you fetch is what you serialize.
2. The "Computed Field" Leak
Adding sensitive fields to an object temporarily for conditional logic on the server, then forgetting they're still on the object when passing it to a client component.
const post = await fetchPost(id); post.isOwner = post.authorId === currentUserId; // Used for server logic post.canDelete = isAdmin || post.isOwner; return <PostCard post={post} />; // authorId, currentUserId, admin status all leaked
3. The "Nested Object" Blindspot
Passing an object that contains nested relations. A post object with an embedded author object, where the author object includes fields like email and role. The developer focuses on sanitising the top-level post fields but forgets the author subtree gets serialised in full.
4. The "Event Handler Context" Trap
Passing sensitive data as props because it's needed in a click handler (e.g., an internal ID for an API call), even though it never renders in the DOM. That handler runs on the client - so the ID must be in the payload. But now anyone inspecting the page can extract those IDs for all items on the page, not just the ones they interact with.
Had Detectable Leaks
Per Payload
To Discovery
TypeScript or Linters
Why Your Linter and TypeScript Won't Save You
This class of vulnerability is invisible to standard developer tooling because it's not a type error, a linting violation, or a runtime exception. The code works exactly as designed. TypeScript sees a valid object being passed as props. ESLint sees syntactically correct JSX. The app runs without errors. The leak is purely semantic - data that shouldn't leave the server is leaving the server, but in a way that requires understanding the full context of what the data represents.
What TypeScript Sees
TypeScript validates that the props you pass match the props the component expects. If UserCard expects a user prop of type User, and you pass an object of type User, TypeScript is satisfied - even if that User type includes 40 fields and UserCard only renders three of them. There's no way to express "this component must only receive these specific fields" in a way that TypeScript can enforce at compile time against the serialisation boundary.
What ESLint Sees
Standard React linting rules check for common mistakes: missing keys, incorrect hook dependencies, unused variables. None of these rules understand the semantic difference between a field that gets rendered (safe to serialise) and a field that exists on an object but never gets accessed (potentially unsafe to serialise). Custom ESLint rules can be written to catch SELECT * patterns or to enforce explicit field selection, but they require deep integration with your ORM or data-fetching layer - they're not generalised rules that ship with eslint-plugin-react.
How to Fix It: The Three-Layer Defence
Layer 1: Explicit Prop Projection
Never pass full database objects to client components. Always project to an explicit subset of fields before crossing the boundary.
const users = await db.query('SELECT * FROM users WHERE visible = true'); const safeUsers = users.map(user => ({ id: user.id, displayName: user.displayName, avatar: user.avatar, bio: user.bio, })); return <UserCard user={safeUsers[0]} />;
This is tedious. It's verbose. It feels like unnecessary boilerplate - especially coming from environments where you could pass full objects freely because the API layer acted as an implicit serialisation sanitiser. But in RSC, there is no API layer between your server logic and the client. You are the API layer. Explicit projection is the only way to guarantee that what you mean to send is what actually gets sent.
Layer 2: TypeScript Utility Types for Client Props
Define explicit types for what client components are allowed to receive, and enforce them at the Server Component call site.
// types/client-safe.ts type ClientSafeUser = { id: string; displayName: string; avatar: string; bio: string; }; // UserCard.tsx (Client Component) 'use client'; import type { ClientSafeUser } from '@/types/client-safe'; export function UserCard({ user }: { user: ClientSafeUser }) { // TypeScript now enforces that only ClientSafeUser fields are accessible }
This doesn't prevent you from passing a full database object - TypeScript uses structural typing, so a User type with 30 fields is still assignable to ClientSafeUser if it has the required four fields. But it does make the boundary explicit and creates a review checkpoint. You can lint for this: flag any client component prop type that references a database model type directly.
Layer 3: Runtime Serialisation Validation
For high-sensitivity applications, implement a runtime validator that inspects props being passed to client components and throws an error if they contain fields from a blocklist (email, password_hash, ssn, api_key, etc.).
const SENSITIVE_FIELDS = ['email', 'password', 'ssn', 'stripe_customer_id']; function guardClientProps(props: any) { const checkObject = (obj: any, path = '') => { for (const key in obj) { if (SENSITIVE_FIELDS.includes(key)) { throw new Error(`Sensitive field ${path}.${key} in client props`); } if (typeof obj[key] === 'object') checkObject(obj[key], `${path}.${key}`); } }; checkObject(props); }
Wrap this around every client component during development. It adds runtime overhead, so gate it behind a feature flag that's enabled in dev and staging but disabled in production - or accept the cost in production for the security guarantee.
What Frameworks Could Do (But Currently Don't)
This problem is solvable at the framework level. React (or Next.js, or any RSC-enabled framework) could implement several defences that would make these leaks far less likely:
- Opt-in serialisation markers. Require developers to explicitly mark which fields of an object should be serialised when crossing the client boundary. Default to denying serialisation of any field not explicitly marked. This inverts the current model (serialize everything by default) and makes security the path of least resistance.
- Dev-mode serialisation warnings. In development builds, log a warning when a client component receives props containing more than N fields (configurable, default 10), or when props contain fields matching common sensitive field names (email, password, token, key).
- Compile-time taint tracking. Extend the React compiler to track which data comes from sensitive sources (database queries, API calls with auth tokens) and error at build time if that data is passed to a client component without going through an explicit sanitisation function.
- Automatic prop projection. If a client component's TypeScript definition declares it accepts a type with five fields, automatically strip any additional fields from objects passed to it during serialisation. This would require tight TypeScript integration but would make over-serialisation impossible even if the developer passes the full object.
None of these exist today. React's security model for RSC assumes that developers understand the serialisation boundary and will manage it correctly. That assumption doesn't hold in large teams with varying levels of RSC expertise.
RSC is still relatively new in production. The framework teams are focused on stability, performance, and DX for the happy path. Security tooling often lags behind feature delivery - not because it's unimportant, but because the patterns that create vulnerabilities only become clear once the feature is widely deployed and misused at scale.
The good news: once a few high-profile leaks happen, frameworks tend to respond quickly. This is a solvable problem. It just hasn't been solved yet.
How to Audit Your Codebase Today
If you're running RSC in production, here's how to check if you have this vulnerability:
- Grep for client boundaries. Search for files containing "use client". These are your client components. For each one, identify where it's called from a Server Component and what props it receives.
- Trace prop sources. For each client component call site, trace back where the props object originates. If it comes directly from a database query, an ORM call, or an external API response without intermediate projection, flag it.
- Inspect RSC payloads in production. Open your production app with DevTools. Navigate to a page that renders user data, admin dashboards, or anything with access control. Find the RSC request (look for RSC headers or _rsc query params). Inspect the response body. Can you see fields that shouldn't be public? User emails, internal IDs, role flags?
- Automated scanning. Write a script to parse your codebase's AST (using TypeScript Compiler API or Babel), find all client component definitions, extract their prop types, and flag any prop type that references a database model type or includes fields from a sensitive field list.
- Pentesting. If you have a security team, ask them to inspect RSC payloads specifically as part of routine security audits. This isn't covered by standard OWASP testing patterns yet because RSC is new - it needs to be called out explicitly.
The Broader Pattern: When Security Boundaries Aren't Code Boundaries
This vulnerability is a specific instance of a broader problem: the security boundary (what data should be accessible to the client) doesn't align with the code boundary (where Server Component code ends and Client Component code begins). In traditional React apps with separate API layers, these boundaries were naturally aligned - the API endpoint was both a code boundary (backend vs frontend) and a security boundary (server-side auth and filtering before sending data).
RSC collapses the API layer into the component tree. That's the entire point - it's why RSC is powerful. But it means the security boundary is now implicit, enforced only by developer discipline and code review, not by architectural separation. You can pass a database object directly to a component three lines down in the same file - one of those components is a Server Component (runs on server, data stays on server) and one is a Client Component (data gets serialised and sent to browser), but visually they look identical.
This is a fundamental DX vs security tradeoff. RSC optimises for developer velocity - you can fetch data and render it in the same file, in the same function, with no API boilerplate. The cost is that the serialisation boundary is invisible and easy to cross incorrectly. Fixing this doesn't mean abandoning RSC - it means developing new patterns, new tooling, and new team practices that make the boundary visible and safe by default.
"The security boundary and the code boundary are no longer the same thing. Until tooling catches up, the burden is on developers to track which data is safe to serialise - and that's a recipe for mistakes at scale."
What This Teaches Us About New Paradigms
Every new paradigm shift in frontend architecture brings a category of security vulnerabilities that weren't possible before. When we moved from server-rendered PHP apps to client-side SPAs, we introduced XSS vectors through dynamic innerHTML and unsafe data binding. When we adopted GraphQL, we introduced query-depth DoS attacks and field-level authorization bypass bugs. Now, with RSC, we're introducing serialisation boundary leaks.
The lesson isn't that RSC is insecure - it's that new paradigms require new security models, and those models take time to develop. Early adopters pay the cost of discovering the vulnerabilities. Frameworks then respond by building in defences. Eventually the vulnerability class becomes well-known enough that developers avoid it by default.
Right now, RSC is in the "early adopters pay the cost" phase. Most teams building with RSC today are learning these lessons the hard way - by shipping a leak, getting reported, and retrofitting mitigations. The teams that ship RSC apps in 2027 will inherit a better framework, better tooling, and better default patterns. But today, in mid-2026, you're building in an environment where the framework gives you all the rope you need to hang yourself, and it's your responsibility to notice the rope is there.
What to Do Right Now
If you're shipping React Server Components in production, here are the actionable steps to take this week:
- Audit your existing RSC payloads. Open DevTools, inspect the network traffic, and verify that no sensitive fields are being serialised.
- Establish a code review checklist item: "Are we passing full database objects to client components, or explicit projections?"
- Implement explicit ClientSafe types for high-risk components (anything rendering user data, PII, or access-controlled content).
- If you have security engineering resources, ask them to add RSC payload inspection to your security audit process.
- Share this with your team. The vulnerability is only obvious once you've seen it - until then, it's invisible.
The serialisation boundary is not a theoretical security concern. It's a real, actively exploited attack surface that most RSC applications have not yet defended. The fix isn't complicated - it's discipline. Treat the client boundary like an API endpoint, because that's what it is. Filter your data, project your fields, and never trust that "it's just props" means "it's safe to send."
React Server Components Next.js Serialisation Security OWASP
For teams serious about securing their RSC applications, consider implementing automated serialisation guards, integrating static analysis tools that understand the server/client boundary, and establishing explicit data classification policies that define what can and cannot be serialised.
Related essays
- Vite · Next.js
VITE_ And NEXT_PUBLIC_ Are Not Naming Conventions. They Are Publish Buttons.
Renaming a variable so it stops being undefined pastes its value into a public JavaScript file. What client env prefixes really do at build time, the dynamic lookup that inlines every prefixed variable in Vite, the define config that leaked a whole shell environment, and a CI check that catches it.
- Vite · Frontend Infrastructure
We Blamed Vite For A 90-Second Local Load. The Real Bottleneck Was The 11,516 Modules We Asked It For.
A local Vite page took 90 seconds to load because eager route imports pulled 11,516 modules into one page load. Making routes lazy cut it to 54 modules and 10.54 seconds. The mechanism, how to count your own modules, and where the fix stops working.
- Frontend Reliability · Web Performance
Every Browser Retry You Add Is A Small DDoS Attack. Stop Calling It Reliability.
Retries keep a single request alive, but at production scale they can turn an outage into a traffic multiplier. The browser needs a retry policy, not a setTimeout loop.