The secret that isn’t in your JavaScript
A Server Component passes an API key to a Client Component as a prop. The code is correct React, the key is in no .js file, and View Source is enough to read it.
Ask an assistant to show the current user’s billing status and you might get something like this. It’s idiomatic, it type-checks, and it works.
// app/page.tsx (Server Component)
export default async function Page() {
const key = process.env.STRIPE_SECRET_KEY!;
return <BillingWidget apiKey={key} />; // ← Client Component
}Nothing here looks like a mistake. The secret is read on the server, from a variable with no NEXT_PUBLIC_ prefix. Every rule you’ve been taught about Next.js environment variables has been followed.
Where it actually goes
React has to get that prop to the browser somehow, because the Client Component needs it to hydrate. So it serialises the value into the RSC flight payload, which is embedded in the HTML your server sends, in a series of self.__next_f.push([1, "…"]) calls.
The key is now readable with View Source. It is not in any JavaScript bundle, which means every scanner that works by downloading and grepping your .js files will report your app as clean.
Why grepping the HTML isn’t enough either
The obvious next thought is to just scan the HTML too. That catches the easy case, and misses three real ones, because the payload is chunked and JSON-escaped rather than sitting there as plain text.
- Escaped values. A private key with its newlines becomes \n inside a JS string literal, so the raw pattern never matches.
- Split values. A long key can straddle two push() calls, with the first half at the end of one chunk and the rest at the start of the next.
- Quoted prop names. In the payload it’s "serviceRoleKey":"…", but heuristics that look for an unquoted identifier followed by a colon won’t fire on that shape.
So Flare.ai reassembles the stream instead: collect every push() chunk in document order, JSON.parse each one to undo the escaping, concatenate, then scan the reconstructed payload. Values split across a chunk boundary become whole again, escaped content becomes literal, and quoted prop names are matchable.
The fix is architectural, not textual
You can’t escape your way out of this. If a Client Component receives a secret as a prop, that secret is public, that’s what “client component” means. The work has to stay on the server, with only the result crossing the boundary.
// do the privileged call on the server; pass down the answer
export default async function Page() {
const status = await getBillingStatus(); // key never leaves the server
return <BillingWidget status={status} />;
}Then rotate the key. It has been in your page source on every render since the day that prop was added.
Written by the Flare.ai team.