Six ways a secret reaches a Next.js client
Six routes by which a server-side value ends up in a Next.js client bundle, including four that never involve the NEXT_PUBLIC_ prefix at all.
You have audited your NEXT_PUBLIC_ variables. It is the right place to start and it covers roughly a third of the ways a value crosses the boundary.
The other routes matter more, because nobody is looking at them. Each one below has a mechanism and a check.
The six routes
1. The prefix
The documented one. A variable named NEXT_PUBLIC_* is substituted into client-bound code at build time as a literal string.
const client = createClient(url, process.env.NEXT_PUBLIC_SUPABASE_KEY!);
Check: list every prefixed variable in your environment and say out loud what each value is. The prefix is not the problem; the value behind it is.
2. Props crossing the server-to-client boundary
The most common route that is not the prefix, and the one Next.js's own security guidance singles out. A server component reads a value safely at runtime and then passes it — or the object containing it — to a client component. Everything crossing that boundary is serialised into the payload sent to the browser.
// server component
const config = await getConfig(); // includes apiSecret
return <Dashboard config={config} />; // client component
Nothing here has a prefix. Nothing is in .env in a way that looks wrong. The whole object ships, including the fields the client never reads.
Check: for every client component, look at what it receives rather than what it uses. Pass the three fields it needs, never the object they came in.
3. Values baked into a statically generated page
A page rendered at build time embeds its data in the generated HTML and in the client-side data payload used for hydration. If the data-fetching function returned something sensitive — an internal identifier, a full user record, a token — it is in the served page whether or not any component displays it.
Check: fetch a statically generated page and read the hydration payload, not just the visible markup.
curl -s https://example-app.test/some-page | grep -oE '"[A-Za-z0-9_-]{40,}"' | sort -u
4. Configuration objects imported by both sides
A shared config.ts that reads several environment variables and exports one object. A server module imports it legitimately. A client component imports it for one constant. The bundler follows the import graph, and everything reachable from that module gets pulled into the client chunk.
The barrel export makes this worse: export * from './config' in an index.ts means a client component importing anything from that directory can drag in the whole thing.
Check: search your client components for imports from shared config or barrel files, and split server-only values into a module that no client code can reach. import 'server-only' at the top of such a module turns this from a convention into a build error.
5. Error responses that echo configuration
An API route catches an exception and returns the error object, or a development-friendly handler serialises the request context into the response. Connection strings and credentials appear in exception messages more often than anyone expects, because the libraries that throw them are trying to be helpful.
Check: trigger a failure in production and read the response body. If it contains anything beyond a message and a code, fix the handler.
6. Client code importing a server module
A utility file that happens to construct a database client at module scope, imported by a component that only wanted a formatting helper. The client instantiation runs in the bundle, and the credential it was built with is inlined to make that possible.
Check: import 'server-only' in every module that touches a secret. It cannot be bypassed accidentally, and it fails at build time rather than in production.
The mitigation worth knowing about, with a caveat
React's taint APIs — experimental_taintObjectReference and experimental_taintUniqueValue — let you mark a value or object as confidential so that passing it across the client boundary throws. It targets route 2 directly and it is genuinely useful.
Two honest caveats. They are still experimental, so treat them as a safety net rather than a design. And Next.js's own guidance is explicit that they are an additional layer: you should still filter data before it reaches the render context, because tainting catches the object you remembered to taint.
The prefix is the route everybody audits. The other five are the ones that are still there afterwards.
Three checks that cover all six
Draw the boundary in code, not in your head
Put import 'server-only' at the top of every module that reads a secret. Route 4 and route 6 become build failures instead of production findings, and the boundary stops depending on anybody remembering where it is.
Read what you serve, not what you wrote
Fetch your deployed chunks and your hydration payloads and search them for credential formats. This is the only check that covers all six routes at once, because it examines the output rather than reasoning about the paths into it.
BASE=https://example-app.test
curl -s "$BASE" | grep -oE '/_next/static/[^"]+\.js' | sort -u \
| while read -r c; do curl -s "$BASE$c"; done \
| grep -oE '(eyJ[A-Za-z0-9_-]{20}|sk_live_[A-Za-z0-9]{8}|sk-proj-[A-Za-z0-9]{8}|postgres://[^"]{8})'
Review props at the boundary
In review, for any diff touching a client component, ask what it now receives. Not what it renders — what it is handed. That single question catches route 2 and route 3, which between them account for most of what survives a prefix audit.
Common questions
Does the App Router make this better or worse?
Better in that server components let you keep far more code and data server-side by default, so there is less reason to send a secret to the browser at all. Worse in that the boundary is now something you cross by writing a prop rather than by choosing a data-fetching function, which makes it easier to cross without noticing. The net is favourable, and it moves the risk from configuration to composition.
Is NEXT_PUBLIC_ ever the right answer?
Frequently. Publishable keys, project URLs, analytics identifiers and public tokens all belong in the client and the prefix is how you put them there. The question to ask about a prefixed variable is never whether the prefix is appropriate — it is whether that particular value is one you would be content to print on your homepage.
What about server actions?
They are a server-side execution path, so the credential itself stays put — but the action's arguments and return value cross the boundary in both directions, and the return value is the one to watch. Returning a whole record from an action is the same mistake as route 2, arriving from the other direction.