Sensitive data in React: the lesson from Google's black box
A badly applied black box on a public document showed what Google wanted to hide: how much water and power the data center in Lincoln, Nebraska, is expected to use. According to the 1011 Now report, the information that should have been hidden was still in the file. It sounds like a paperwork problem, but it isn't. It's the same mistake a lot of devs make every day with sensitive data in React. I've made it too.
What happened in Lincoln
The story is simple. Google has a data center project in Lincoln. The city and the local power utility have to publish documents about the deal. The water and electricity numbers were treated as confidential, so someone put black boxes over them.
The problem is that the boxes were only on top. The text underneath was still there, readable for anyone who knew where to look. The result: a story that was supposed to be about "what we don't know" turned into "look what you can see". And it left more questions than answers, because now everyone wants to know why those numbers were hidden.
I won't get into the debate about data center water use. It matters, but what interests me here is the technical failure. It's identical to something I see in code review all the time.
Why a black box fails
In a PDF, drawing a black rectangle over text doesn't delete the text. There are two layers: the original content and a drawing on top. The PDF reader shows the box, but the text stays in the file. You select it, copy it, paste it into a text editor, and that's it.
Real redaction removes the content from the file. Serious tools do this and also clean up metadata, revision history and hidden layers. Whoever just draws the square is doing decoration.
Hiding it on screen is not the same as not sending it.
Keep that sentence in mind. It applies to PDFs, and it applies even more to your frontend.
Sensitive data in React: the frontend's black box
In React, the "black box" takes many forms. The classic one is this:
function UserCard({ user }: { user: User }) {
return (
<div>
<h2>{user.name}</h2>
{user.role === 'admin' && <p>SSN: {user.ssn}</p>}
</div>
)
}
You look at it and think: "the SSN only shows up for admins". On screen, that's true. But the whole user object reached the browser. Open React DevTools, click the component, and the SSN is right there in the props. Or open the Network tab and read the API JSON.
Other versions of the same mistake I've run into:
display: noneorhiddenon a block with data the user shouldn't see.- An
/api/users/:idendpoint that returns the full database record, includingpassword_hash, while the frontend only uses three fields. - A feature flag hiding a button, while the action's route is still open on the backend.
- An environment variable with the
NEXT_PUBLIC_orVITE_prefix holding a key that should be secret.
In all of these cases, the interface is the black box. The data is in the file. In other words, in the bundle, the payload, the HTML.
The one that catches even experienced devs: Server Components
With React Server Components, the problem got subtler. Many people think everything that runs on the server stays on the server. It does, until you pass it to a Client Component.
// app/profile/page.tsx (Server Component)
export default async function Page() {
const user = await db.user.findUnique({ where: { id } })
return <ProfileForm user={user} /> // ProfileForm has 'use client'
}
Anything passed as a prop to a component with 'use client' gets serialized and sent to the browser. The whole object. If user has password_hash, stripe_customer_id and internal_notes, all of it is in the RSC payload. You can see it in the page HTML, in the flight chunks. In the Pages Router it was the same with getServerSideProps: everything ended up in __NEXT_DATA__.
The fix is almost annoyingly simple. Select only what the screen needs:
const user = await db.user.findUnique({
where: { id },
select: { name: true, email: true, avatarUrl: true },
})
return <ProfileForm user={user} />
Whether you use Drizzle, Prisma or plain SQL, the idea is the same: SELECT * is the black rectangle drawn on top.
How to stop the leak in your React project
You don't need a magic tool. You need a few rules the whole team follows.
1. Build a DTO at the boundary. Every time data leaves the server, it goes through a function that picks the fields. It can be a simple pick or a Zod schema with .parse(), which drops anything not declared.
const PublicUser = z.object({
name: z.string(),
avatarUrl: z.string().url().nullable(),
})
return PublicUser.parse(user) // extra fields are gone
2. Mark what must never reach the client. The server-only package breaks the build if a server module gets imported into a Client Component. React also has the taint APIs (experimental_taintObjectReference and experimental_taintUniqueValue), which Next.js exposes with the taint: true flag. They make React throw an error if that object or value tries to cross over to the client.
import { experimental_taintObjectReference } from 'react'
const user = await getUserFromDb(id)
experimental_taintObjectReference('Do not pass the raw user to the client', user)
It's not a silver bullet. If you copy the field into another object, the taint doesn't follow. But it catches the most common mistake, which is passing the whole record without thinking.
3. Authorization lives on the backend. Hiding the "delete" button from non-admins is UX. Blocking the DELETE on the server is security. You need both, but only one of them protects anything.
4. Run the nosy-user test. Before shipping a feature, open the Network tab, look at the payload of each request and ask: "would I be fine if this showed up in a screenshot on X?". It takes two minutes. It's the equivalent of selecting the text under the black box and seeing if it pastes.
What this story changes in your day to day
The Lincoln case is a good reminder that leaks don't always come from sophisticated hackers. Sometimes they come from someone who trusted the visual layer. In the PDF it was a rectangle. In React it's a &&, a hidden, one prop too many.
I think, and this is the strong opinion of this post, that the React ecosystem made this mistake far too easy in recent years. The line between server and client became invisible on purpose, to be ergonomic. That's great for productivity and terrible for anyone who doesn't know where the line is. The 'use client' at the top of the file is, in practice, a customs checkpoint. Everything that crosses it is declared to the world.
So treat it that way. Before passing a prop from a Server Component to a Client Component, ask what's inside it. Before returning a JSON, look at the fields. And be suspicious of any data that "just doesn't show up on screen".
Google will survive the embarrassment. Your app might not survive an SSN leak. If you want to see how I organize this server and client boundary in my own work, take a look at the projects I've published.
LinkedIn summary
A badly applied black box exposed what Google wanted to hide about the Lincoln data center. The text was still in the PDF. Someone had just drawn a rectangle on top. In React we make the same mistake every day: a `&&` that hides the SSN on screen but sends the whole object to the browser. Server Components made it worse. Anything passed as a prop to a `'use client'` component goes along in the payload. Hiding it on screen is not the same as not sending it. DTO at the boundary, `select` only what you need, and authorization on the backend. I wrote about it on the blog, with code and a quick checklist. Worth the 5 minutes before your next deploy. #React #NextJS #InfoSec #WebDevelopment #FrontEnd