server-only and secrets: the module graph, NEXT_PUBLIC_, and what taint APIs actually catch
Secrets leak through the client module graph and serialized props, not just the network. import server-only turns a wrong import into a build error; NEXT_PUBLIC_ vars are inlined into the bundle forever; taint APIs are an experimental tripwire, not a security boundary.
The pentest report’s first finding needs no exploit chain: “API secret present in public JavaScript bundle.” The tester greps the deployed chunks under _next/static/, and there it is — a production API key, in plaintext, shipped to every visitor for the past three months. Git archaeology finds the moment it happened: a client component needed to call an internal API, the fetch failed with an undefined key, and a developer “fixed” it by renaming API_SECRET to NEXT_PUBLIC_API_SECRET. The fetch turned green, the PR said “fix env var not loading,” review approved it in minutes. Nobody was reckless — the developer did what the error message seemed to suggest, and nothing in the build warned that NEXT_PUBLIC_ means inline this string into the bundle at build time, permanently. The fix took an hour. The key rotation, the audit of three months of access logs, and the customer disclosure conversation took three weeks. This lesson is about making that rename impossible.
The module graph decides what ships to the browser
If you have ever wondered how a secret could end up in a public JS bundle when you never intentionally put it there, the answer is the transitive import rule — and understanding it changes how you structure any module that touches credentials.
In the App Router, “server code” and “client code” are not separate folders — they are regions of one module graph, split by the 'use client' directive. A file with 'use client' and everything it imports, transitively, must be bundleable for the browser. That transitive clause is where leaks live: you import one innocent helper from lib/billing.ts into a client component, and the bundler pulls the entire module — including the parts that touch secrets — into the client graph. The module’s top-level code now executes in the browser; any string literal in it ships verbatim in a public chunk.
Environment variables follow two different rules, and conflating them is the classic mistake. Plain server env vars (process.env.STRIPE_SECRET_KEY) are read at runtime on the server; if that code ends up in a client bundle, the value is undefined in the browser — a crash or a silent failure, embarrassing but not a leak. NEXT_PUBLIC_-prefixed vars are the opposite: they are inlined at build time — the bundler string-replaces process.env.NEXT_PUBLIC_X with the literal value everywhere it appears. Three consequences: the value is visible to anyone who opens devtools; it cannot be rotated without a rebuild and redeploy (the old value lives on in cached chunks until they expire); and the prefix is a one-way door — once a secret has shipped under NEXT_PUBLIC_, it is compromised, full stop, and rotation is the only remedy. The prefix is for genuinely public config: analytics ids, public API URLs. Anything you would not paste into a tweet does not get the prefix.
server-only: a build-time tripwire
The defense is not vigilance — it is making the wrong import fail the build. The server-only package contains no runtime code; its trick is in package.json export conditions: when resolved in a server context it resolves to an empty module, and when a client bundle tries to include it, it resolves to a module that throws a build error. One import line at the top of any module that must never reach the browser turns “a developer imported the wrong file” from a silent leak into a red build:
// lib/billing.ts
import 'server-only'; // client import of this module = build error
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function chargeCustomer(customerId: string, amountCents: number) {
return stripe.charges.create({ customer: customerId, amount: amountCents, currency: 'usd' });
}
// The innocent-looking helper that tempts client imports — now safely fenced:
export function formatAmount(cents: number) {
return (cents / 100).toFixed(2);
}The discipline that makes this cheap: put import 'server-only' in every module that reads secrets, touches the database, or wraps a privileged SDK — reflexively, the way you write types. Pure formatting helpers that clients legitimately need go in their own lib/format.ts with no secret-bearing siblings, so nobody is ever tempted to import across the fence. The mirror package client-only exists for the reverse mistake (browser-API code pulled into server rendering). The cost of the tripwire is one import line; the failure it converts is the three-week incident from the Hook into a thirty-second build error pointing at the offending import chain.
A fetch in a client component fails because process.env.API_SECRET is undefined in the browser. A developer renames it to NEXT_PUBLIC_API_SECRET and the fetch works. What actually happened?
Data leaks: serialized props, DTOs, and what taint actually does
Code is one leak channel; data is the other. Every prop you pass from a server component to a client component is serialized into the RSC payload — embedded in the HTML document and in subsequent navigation responses, fully visible in view-source. The classic incident shape: getUser() returns the whole database row, a server component passes user to a client <ProfileCard>, and passwordHash, mfaSecret, and stripeCustomerId are now in the page source for anyone — no breach, no exploit, just serialization doing its job on data that should never have crossed. The structural fix is the DTO discipline from the auth docs: data-access functions return explicitly constructed objects with the fields the UI needs, never the raw row. The leak then requires someone to add a sensitive field to a DTO on purpose, instead of requiring everyone to remember to strip it everywhere.
React’s taint APIs are a second tripwire for this channel, and they deserve an honest description. experimental_taintObjectReference(message, user) makes React throw at render time if that exact object reference is passed to a client component; experimental_taintUniqueValue(message, lifetime, secret) does the same for a specific primitive value. The limits matter as much as the feature: both are experimental (behind the taint flag); object tainting tracks the reference, so { ...user } produces an untainted copy that sails through; value tainting tracks the exact value, so any derivation — uppercase, base64, substring — escapes it. And both fire at runtime, not build time: a taint violation on a rarely rendered admin page is found when that page renders, not when it merges. Taint is defense-in-depth that catches the honest mistake of passing a flagged object directly; it is not a security boundary, and it does not replace DTOs — it backs them up.
▸Why this works
Why does the framework not just block sensitive data automatically? Because the serialization layer cannot know what is sensitive — a passwordHash and a productDescription are both strings, and only your domain model knows the difference. Hence the layered design, each layer catching what the previous one cannot: server-only fences code at build time (cheap, total, but only for modules you remembered to mark); DTOs shape data structurally (the field is absent, so it cannot leak); taint catches the residual case where a flagged object is passed directly (runtime, reference-based, experimental). Three imperfect tripwires, layered, approximate the boundary that no single mechanism can provide.
A team taints their user object with experimental_taintObjectReference right after the DB read. A server component then passes { ...user } to a client component. What happens?
- 01What are the two distinct leak channels for server secrets in the App Router, and what guards each?
- 02What exactly do NEXT_PUBLIC_ and the taint APIs each do, and what are their hard limits?
The App Router has no wall between server and client code — only one module graph divided by ‘use client’, and the bundler’s rule is transitive: everything a client file imports, ships. That is the first leak channel: import one innocent helper from a module that also constructs a Stripe client, and the whole module joins the public bundle. Environment variables split into two regimes — plain server vars read at runtime (undefined in the browser; a crash, not a leak) and NEXT_PUBLIC_ vars inlined at build time as string literals: visible to all, unrotatable without a rebuild, and a one-way door — a secret shipped under the prefix is compromised by definition, as the rename-to-make-the-fetch-work incident demonstrates. The guard for the code channel is import ‘server-only’ in every module that touches secrets, databases, or privileged SDKs: an empty module on the server, a build error in any client graph, converting a three-week key-rotation incident into a thirty-second build failure. The second channel is data: props from server to client components serialize into the RSC payload, embedded in the page — pass a raw user row and passwordHash is in view-source with no exploit required. The structural guard is the DTO discipline: data access returns explicitly constructed objects, so leaking requires adding a field deliberately rather than forgetting to strip it. The taint APIs — experimental_taintObjectReference and taintUniqueValue — are an honest tripwire on top: they throw at render time when the exact flagged reference or value crosses the boundary, but copies, spreads, and derivations escape them, they require an experimental flag, and they fire at runtime, not build time. Now when you reach for a helper from a server module inside a client component, you will pause — and that pause is the whole point of understanding the transitive rule. Fence the code, shape the data, taint as backup — in that order.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.