Running every database query on the server does not stop data from reaching the browser. The query runs server-side, the result is correct, and then you pass it as a prop to a Client Component, where it is serialized into the React Server Component payload and shipped to the browser in full. Backend-only data access governs where the query runs. It says nothing about what you serialize across the boundary.
This is the output-side companion to backend-only data access. That guide closes the input side: the browser never holds a credential that can query the database, so every read and write happens through a Server Action or admin client. This post starts one step later. The query already ran on the server, correctly. The leak happens on the way back, when a Server Component hands too much of the result to a Client Component.
The mistake is easy to miss because nothing breaks. The page renders, the feature works, and the extra fields sit invisibly in the page source until someone opens the network tab.
TL;DR:
- The RSC payload carries your props. Next.js states the payload contains "Any props passed from a Server Component to a Client Component" [1]. Whatever you pass is in the browser, rendered or not.
select('*')is the usual culprit. The Next.js security guide marks the pattern verbatim: passing a full row to a Client Component "exposes all the fields in userData to the client" [2].- Shape data on the server, then pass it. Build a Data Transfer Object that returns "only the data relevant for this query and not everything" [2]. Explicit-column selects beat
select('*'). - Taint APIs are a backstop, not the fix. React's taint functions block whole objects and raw secrets from crossing [3][4], but they "do not protect against extracting data fields out of this object and passing them along" [2].
- Hidden UI still ships its data. A value passed to a Client Component that conditionally renders it is in the payload even when the component renders nothing.
Table of Contents
- Why doesn't backend-only data access stop the leak?
- What does the RSC payload actually send to the browser?
- Which server-fetched values leak across the boundary?
- How do you shape data before it crosses the boundary?
- Do the React taint APIs stop accidental leaks?
- Building the output boundary into a Next.js and Supabase app
Why doesn't backend-only data access stop the leak?
Backend-only data access controls where a query executes. It does not control what you send to the browser afterward. These are two different boundaries. The input boundary keeps the database credential off the client so the browser cannot run its own queries. The output boundary governs what fields cross from a Server Component into a Client Component. You can get the first one perfect and still leak through the second.
The reason is structural. A Server Component fetches a row with the service role key, which bypasses Row Level Security (RLS), so the row arrives complete: every column, including the ones the current user is not meant to see. That is fine while the data stays on the server. The moment you pass that row to a Client Component as a prop, it leaves the server. CWE-200 names the resulting weakness class plainly: "The product exposes sensitive information to an actor that is not explicitly authorized to have access to that information" [5]. The unauthorized actor here is the browser itself, and the exposure is silent because the UI looks correct.
So the two halves of the pattern answer different questions. Backend-only access answers "can the browser query my database?" The output boundary answers "did I just hand the browser data it should never receive?" A complete defense needs both.
What does the RSC payload actually send to the browser?
Everything you pass as a prop from a Server Component to a Client Component is serialized and sent to the browser. Next.js is explicit: the React Server Component payload "contains" three things, the rendered output of Server Components, placeholders for Client Components, and "Any props passed from a Server Component to a Client Component" [1]. Props are not a server-side detail. They are transmitted data.
The official Next.js security guide spells out the consequence with a worked example. A page reads a full user row and renders a profile, passing the row straight through:
export async function Page({ params: { slug } }) {
const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`
const userData = rows[0]
// EXPOSED: This exposes all the fields in userData to the client because
// we are passing the data from the Server Component to the Client.
return <Profile user={userData} />
}
That comment, "EXPOSED: This exposes all the fields in userData to the client," is from the guide itself [2]. The Client Component only renders user.name, but userData arrives with every column the table has. The guide is just as blunt about the receiving end, calling a Client Component prop typed as the whole user object "a bad props interface because it accepts way more data than the Client Component needs and it encourages server components to pass all that data down" [2].
One constraint shapes the whole problem: props "need to be serializable by React" [1]. You cannot pass a function or a class instance, but you can pass any plain object, and a database row is a plain object. The type system does not warn you, because a row with ten columns satisfies a prop typed as the same row. The leak is invisible at the type level and invisible in the rendered page. It shows up only when someone reads the payload.
Which server-fetched values leak across the boundary?
Five output-side patterns ship more than the UI shows. None of them are about where the query runs. Each is about handing a Client Component more than it needs. They are distinct from the input-side mistakes in the backend-only data access guide; these are the ways a correct server-side query still ends up in the browser.
1. select('*') passed straight to a Client Component. The broad select is the root cause. A profiles row fetched with select('*') carries every column you have ever added: a soft-delete flag, an internal role, a stripe_customer_id, a note a support agent left. Render full_name in a Client Component and you have shipped all of it. Backend-only access made the query safe; the broad select made the result dangerous to pass along.
2. The whole auth user or session object. Supabase's auth.getUser() returns a rich object: email, phone, and the full app_metadata and user_metadata blobs, which often hold role and claim data. Passing that object to a Client Component to greet the user by name ships their email, phone, and metadata too. The fix is to read the one field and pass the field, not the object.
3. Data fetched for conditionally rendered UI. A Server Component fetches an admin-only panel's data, then a Client Component renders it only when a client-side isAdmin flag is true. If that data is passed as a prop, it is serialized into the payload whether or not the panel ever renders. Hiding a component in the browser does not remove its props from the wire. Gate the fetch on the server with a re-verified check, do not fetch broadly and hide.
4. Joined rows that expose another entity. A query like select('*, owner:profiles(*)') pulls a related user's entire row alongside the resource you wanted. Pass the result to the client and you have leaked a second person's data, which is the output-side face of an insecure direct object reference (IDOR). Select only the joined fields you render, and authorize the join the same way you would authorize a direct read.
5. Error and debug objects. Catching an error and passing it to the client for a debug panel, or throwing one whose message embeds a value, leaks through the error path. The Next.js guide gives the canonical shape: "The error messages and stack traces might end up containing sensitive information. E.g. [credit card number] is not a valid phone number" [2]. Production React replaces the message with a hash, but "in development mode, server errors are still sent in plain text to the client" [2], and a raw caught error you deliberately pass as a prop bypasses that protection entirely.
| Leak | What ships | Fix |
|---|---|---|
select('*') to client prop | Every column of the row | Select explicit columns |
| Whole user/session object | Email, phone, metadata | Pass the single field |
| Conditionally rendered data | Hidden value, still in payload | Gate the fetch on the server |
| Joined rows | Another entity's full row | Select rendered fields, authorize the join |
| Error / debug objects | Embedded sensitive values | Return safe shapes, never raw errors |
How do you shape data before it crosses the boundary?
You shape data on the server by returning a purpose-built object that contains only the fields the client needs, not the raw row. The Next.js security guide calls these Data Transfer Objects: methods that "expose objects that are safe to be transferred to the client as is" [2]. Instead of select('*') and passing the row, you select the columns you render, or you map the row to a narrow object whose every field is safe by construction.
The guide's principle is worth quoting directly: "only return the data relevant for this query and not everything" [2]. In a Supabase app, that starts with the select itself. Replace the broad query with an explicit column list, so the database never hands you the fields you would have to remember to drop later:
import { createAdminClient, getUser } from '@/lib/supabase/server'
// A Data Transfer Object: only the fields the UI renders.
export async function getProfileForClient() {
const user = await getUser()
if (!user) return null
const admin = createAdminClient()
const { data } = await admin
.from('profiles')
.select('id, full_name, avatar_url') // not select('*')
.eq('id', user.id)
.single()
// Safe to hand to a Client Component as is.
return data
}
This is a deliberate change from the convenience helper. SecureStartKit ships a getUserWithProfile() in lib/supabase/server.ts that does select('*') and returns the full auth user object alongside the full profile row. That is the right shape for a Server Component that reads one field and renders it on the server. It is the wrong shape to hand to a Client Component, because the whole object would serialize. The discipline is to keep the broad helper for server-side rendering and build a narrow DTO at the point where data crosses into a 'use client' component.
The same logic applies to validating what comes back, not just what goes in. The Server Actions and Zod guide covers validating inputs; the mirror discipline is being explicit about outputs. The guide's design rule is that "a Server Component function body should only see data that the current user issuing the request is authorized to have access to" [2]. If the function never receives the sensitive field, it cannot leak it. Consolidating reads into a Data Access Layer, the same place this site keeps its admin client and server-only modules, gives you one auditable spot to enforce that.
Do the React taint APIs stop accidental leaks?
The React taint APIs catch some accidental leaks, but they are a backstop, not the primary defense. They let you mark a value so React throws if it ever reaches a Client Component. experimental_taintObjectReference "lets you prevent a specific object instance from being passed to a Client Component like a user object" [3], and experimental_taintUniqueValue "lets you prevent unique values from being passed to Client Components like passwords, keys, or tokens" [4]. You turn them on with the experimental taint flag in your Next.js config [2].
In practice you taint the object inside your data access function, with a message that tells the next developer what to do:
import { experimental_taintObjectReference } from 'react'
export async function getUser(id: string) {
const user = await db.from('profiles').select('*').eq('id', id).single()
experimental_taintObjectReference(
'Do not pass the entire user object to the client. ' +
'Instead, pick off the specific properties you need for this use case.',
user,
)
return user
}
That error message is React's own suggested wording [3]. The honest limitation is the reason taint is a backstop. The Next.js guide states it directly: tainting an object "does not protect against extracting data fields out of this object and passing them along," and "even this doesn't block derived values" [2]. Pull one field off the object, such as the user's email, pass that field on its own, and the taint never fires. So the guide's conclusion is the one to internalize: "It's better to avoid data getting into the Server Components in the first place, using a Data Access Layer. Taint checking provides an additional layer of protection against mistakes" [2]. Shape first with a DTO, then taint the broad objects as a safety net against the next refactor.
Building the output boundary into a Next.js and Supabase app
Data exposure in Next.js has two boundaries, and a secure app defends both. The input boundary is backend-only data access: the browser holds no credential that can query the database, so reads and writes go through Server Actions and an admin client. The output boundary is serialization: a Server Component must hand a Client Component only the fields it renders, because every prop is serialized into the RSC payload and sent to the browser [1].
If you ship one habit from this post, stop passing select('*') rows to Client Components. Select the columns you render, or map the row to a Data Transfer Object whose every field is safe to expose. Pass the field, not the object. Keep broad helpers for server-side rendering only, gate conditional data on the server instead of hiding it in the browser, and never pass a raw error or joined-entity row to the client. Turn on the taint flag as a backstop, knowing it catches whole-object mistakes but not derived fields.
SecureStartKit ships the input boundary by default: backend-only access, RLS deny-all, Zod on every Server Action, and the service role key confined to the server. The output boundary is a discipline you apply on top, at every point data crosses into a Client Component. The SaaS security checklist covers the credential and RLS checks that sit underneath this, and the backend-only data access guide covers the input side in full. Get the query off the browser first, then make sure the result you send back is only what the screen needs. If you want both boundaries pre-wired, that is what SecureStartKit is, and this site is the demo.
Built for developers who care about security
SecureStartKit ships with these patterns out of the box.
Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.
References
- Server and Client Components— nextjs.org
- How to Think About Security in Next.js— nextjs.org
- experimental_taintObjectReference— react.dev
- experimental_taintUniqueValue— react.dev
- CWE-200: Exposure of Sensitive Information to an Unauthorized Actor— cwe.mitre.org
Related Posts
Backend-Only Data Access in Next.js + Supabase [2026]
The architectural pattern that prevents Supabase data leaks. Server Actions, admin client, no NEXT_PUBLIC key for queries, ever.
Secure 'use cache' in Next.js 16: No User Data Leaks
Next.js 16's 'use cache' is easy to misuse. Cache the wrong thing and User A sees User B's data. The three directives explained safely.
Next.js Security Checklist: 12 Steps [2026]
A production security checklist for Next.js apps. Covers HTTP headers, CSP, environment variables, Server Actions, RLS, webhook verification, and more.