DEV Community

Anas Sheikh
Anas Sheikh

Posted on

Everything You Pass to a Client Component Is Public: The Next.js Data Leak Hiding in Your Props

You fetch a user in a Server Component. You pass it to a Client Component that renders the name and the avatar. The page looks perfect, and nothing sensitive appears on screen.

Open "View source" and search for the user's email. Then search for the password hash.

It is there. Every field on that object is sitting in the page, readable by anyone, even though you never displayed it.

The Code That Looks Safe

// app/profile/page.tsx  (Server Component)
import { ProfileCard } from './ProfileCard';

export default async function ProfilePage() {
  const user = await User.findById(session.userId).lean();
  return <ProfileCard user={user} />;
}
Enter fullscreen mode Exit fullscreen mode
// app/profile/ProfileCard.tsx
'use client';

export function ProfileCard({ user }: { user: any }) {
  const [editing, setEditing] = useState(false);
  return (
    <div>
      <h2>{user.name}</h2>
      <img src={user.avatarUrl} alt="" />
      <button onClick={() => setEditing(true)}>Edit</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

It renders two fields. The component uses two fields. The leak is in everything else on user.

Why It Happens

A Client Component runs in the browser, so its props have to get there somehow. Next.js serializes the props, meaning it converts them into text, and sends that text with the page.

You can see it in the HTML as script tags that look like this:

<script>self.__next_f.push([1,"...{\"name\":\"Sara\",\"email\":\"sara@example.com\",\"passwordHash\":\"$2b$12$...\",\"role\":\"admin\"}..."])</script>
Enter fullscreen mode Exit fullscreen mode

The serializer does not know which fields your component reads. It sends the whole object, and the component then picks what to render. Hiding a field in the UI is not the same as not sending it.

Why It Is Easy to Miss

  • Nothing looks wrong on screen. The leak is invisible to anyone who is not reading the page source.
  • TypeScript hides it. With user: any, or a wide type, the compiler never asks which fields you actually need.
  • It works exactly as designed. Server Components make it feel like server code can pass anything anywhere, which is true for other Server Components and false for the client boundary.
  • Database objects grow. Today the record has three fields. Next quarter someone adds stripeCustomerId, internalNotes and apiKey, and they flow into the page without anyone touching this component.

Fix 1: Pass Only What the Component Needs

The simplest and most reliable fix is to pick the fields on the server:

export default async function ProfilePage() {
  const user = await User.findById(session.userId).lean();

  return (
    <ProfileCard
      user={{ name: user.name, avatarUrl: user.avatarUrl }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

The browser now receives two fields, because that is all you sent. Type the prop to match:

type ProfileCardProps = { user: { name: string; avatarUrl: string } };
Enter fullscreen mode Exit fullscreen mode

If a developer later tries to read user.email in the component, TypeScript fails to compile, and that is the safety net you want.

Fix 2: A Data Transfer Function

Picking fields inline works for one page. For anything reused, write a small function that is the only way a user record is allowed to reach a component:

// lib/dto.ts
import 'server-only';

export function toPublicUser(user: UserDoc) {
  return {
    id: String(user._id),
    name: user.name,
    avatarUrl: user.avatarUrl,
  };
}
Enter fullscreen mode Exit fullscreen mode
<ProfileCard user={toPublicUser(user)} />
Enter fullscreen mode Exit fullscreen mode

Now there is one place that decides what is public. Adding a new database field does not expose it, because the function lists fields explicitly, and an allow-list is safer than a block-list.

The server-only import makes the build fail if this file is ever imported from a Client Component.

Fix 3: Exclude Sensitive Fields at the Query

You can also keep the sensitive fields from ever leaving the database layer:

const user = await User.findById(id).select('-passwordHash -apiKey').lean();
Enter fullscreen mode Exit fullscreen mode

With Mongoose, a schema option can also hide a field by default:

passwordHash: { type: String, select: false },
Enter fullscreen mode Exit fullscreen mode

This is a good extra layer, but it is not a replacement for Fix 1 or 2. It is a block-list, and block-lists miss the field you add next month.

An Extra Guard: Taint APIs

React has experimental APIs that mark a value as not allowed to cross into client code, so that passing it to a Client Component throws an error. They are experimental and need to be enabled in your Next.js config, so check the documentation for your version before relying on them. Think of them as a seatbelt, not a replacement for choosing your props.

The Same Rule Applies to Server Actions

Arguments you receive are untrusted input, and what you return goes back to the browser. Returning a full database record from a Server Action leaks it just like a prop does. Return a small, deliberate object.

Check Your Own App in Two Minutes

  1. Open a logged-in page that uses a Client Component with data.
  2. Right-click, choose "View page source".
  3. Search for something sensitive: an email, passwordHash, role, token, internal.

If you find a field you did not intend to show, trace which Client Component received it and apply Fix 1 or 2.

The Rule

Props to a Client Component are public. Pass the fields the component needs, not the object you fetched. Use an allow-list function for anything sensitive, and treat the Server/Client boundary the same way you treat an API response.

Quick audit: search your project for 'use client' files, then look at where each one is rendered and what is passed in. If a prop is a raw database record, you have a leak to close.

I cover more MongoDB and Mongoose patterns in my guide on pixelanas.com, and you can browse my Next.js templates for a starting point.

Get the templates: https://pixelanas.gumroad.com

Did you ever find something you did not expect in your page source? Tell me in the comments what it was.


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (1)

Collapse
 
bedirhandev profile image
Bedirhan Elibol •

Fix 2 is the one that survives time. One addition: turn the "view source" check into a test. Fetch the logged-in page in an end-to-end test and assert the HTML does not contain the field names you never want in the browser (passwordHash, apiKey, stripeCustomerId). Then the field someone adds next quarter fails CI instead of shipping.