← All posts
·8 min read·GuardLayer

Is Your Next.js App Leaking Secrets to the Browser?

Next.jsSecretsSecurityApp Router

Short answer: a Next.js app leaks secrets to the browser in three ways — a NEXT_PUBLIC_ prefix on a secret, a server-only module imported into a Client Component, or a sensitive value passed as a prop to a Client Component. All three ship the secret in plain text inside your JavaScript bundle or HTML, and none of them throw an error. The fix for all three is the same: keep secrets behind the server boundary and hand the client only the minimal data it needs.

If you're asking whether your app is leaking, the fastest answer is empirical: open the page, view source, and search the HTML. Secrets in a Next.js frontend aren't obfuscated — they sit in readable text in the bundle or the hydration payload, where bots scrape them continuously. Here's every path they take to get there, and how to close each one.

How does a Next.js app leak secrets to the browser?

There are three distinct mechanisms, and they fail in completely different parts of your codebase. Knowing which one you're dealing with is half the fix.

Leak #1: A secret behind the NEXT_PUBLIC_ prefix

This is the most common one, and it's a naming mistake, not a logic mistake. Next.js inlines any variable prefixed with NEXT_PUBLIC_ directly into the JavaScript bundle at build time — it replaces every process.env.NEXT_PUBLIC_FOO reference with the literal string before shipping the bundle to every visitor. That's the entire purpose of the prefix: an explicit opt-in to "this is public."

The trap is reaching for it when an unprefixed variable reads as undefined on the client. The variable "works" the instant you add NEXT_PUBLIC_, so it sticks:

// .env
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_...   // catastrophic
NEXT_PUBLIC_OPENAI_API_KEY=sk-...           // billed to you, by strangers

How to detect it. Grep your environment files and source for any secret-shaped name behind the public prefix:

grep -rniE 'NEXT_PUBLIC_.*(SECRET|KEY|TOKEN|PASSWORD|SERVICE_ROLE)' . \
  --include='*.env*' --include='*.ts' --include='*.tsx' --include='*.js'

Then confirm against the source of truth — the built bundle:

grep -rE 'sk_live_|sk-|service_role' .next/static 2>/dev/null

A hit on the second command means that secret is already public. Anything in .next/static shipped to the browser.

The fix. Drop the prefix. Read secrets on the server only, with no NEXT_PUBLIC_. The browser should see only genuinely public values — a publishable Stripe key, an analytics ID, your Supabase project URL. We cover the single-rule version of this in a NEXT_PUBLIC_ leaked API key; the rule of thumb is that a NEXT_PUBLIC_ variable is a billboard, not a vault.

Leak #2: A server-only secret imported into a Client Component

This one is sneakier because the variable name is correct — no NEXT_PUBLIC_ in sight. The leak happens through the import graph. The moment a file marked "use client" imports a module that reads a secret, that module gets bundled for the browser, and the secret rides along.

// lib/payments.ts  — no "use client", looks server-safe
export const STRIPE_SECRET = process.env.STRIPE_SECRET_KEY!;
export function chargeCard(/* ... */) { /* ... */ }
"use client";
import { STRIPE_SECRET, chargeCard } from "@/lib/payments"; // pulls the secret into the bundle

export default function CheckoutButton() {
  // chargeCard is never called on the client — doesn't matter, it's bundled
  return <button>Pay</button>;
}

There's a subtle trap here. Next.js applies server-only protection: an unprefixed process.env.STRIPE_SECRET_KEY read in a client context is replaced with an empty string, so a bare env read often blanks out rather than leaks. But that protection does not cover a constant you computed at module load, a value read through a dynamic key, or a hardcoded literal — those flow straight into the bundle. Treat the empty-string behavior as a fallback, not a boundary.

How to detect it. Find any "use client" file whose import chain reaches a secret:

# files that declare a client boundary
grep -rln '"use client"' app components | \
  xargs grep -lE 'SECRET|SERVICE_ROLE|PRIVATE_KEY|_TOKEN'

The fix. Mark every secret-touching module with import "server-only". It does nothing at runtime, but it turns an accidental client import into a build-time error — the compiler refuses to bundle it.

import "server-only"; // build fails if a Client Component imports this
export function chargeCard(/* ... */) { /* ... */ }

This is the same failure mode behind a leaked Supabase service_role key reaching the browser — a server admin client imported into a "use client" tree. The server-only package is the cheapest guardrail you can add to any file that reads process.env.

Leak #3: A secret passed as a prop into a Client Component

This is the leak most developers don't know about, and it's specific to the App Router. When a Server Component passes props to a Client Component, those props are serialized into the RSC payload and embedded in the HTML — you'll find them in self.__next_f.push(...) script tags when you view source. Per Next.js's own security guidance, the entirety of a Client Component's props is included in the generated HTML, whether or not the component reads them.

So this ships every field of user — including the password hash and API token — into the page source:

// Server Component
export default async function Page({ params }) {
  const user = await db.user.findUnique({ where: { id: params.id } });
  // user = { id, name, email, passwordHash, apiToken, ... }
  return <ProfileCard user={user} />; // ALL of it serialized to the client
}
"use client";
// Renders only name, but receives — and ships — the whole object
export function ProfileCard({ user }: { user: User }) {
  return <h1>{user.name}</h1>;
}

This isn't theoretical. The DOGE government website (doge.gov) left grant identifiers in its RSC payload for several days — data that had been removed from the visible page was still sitting in the escaped JSON of self.__next_f.push(...), readable in page source, as the New York Times reported.

How to detect it. Load the page, view source (or curl it), and search the HTML for a field that should never be there:

curl -s https://your-app.com/profile/123 | grep -o 'passwordHash\|apiToken\|service_role'

Then audit your "use client" prop types: any Client Component accepting a broad object like user: User is a candidate. Next.js's guidance is blunt — a Client Component should not accept more data than the minimal data it needs to perform its job.

The fix. Shape the data on the server before it crosses the boundary. Pass primitives, not whole records:

return <ProfileCard name={user.name} avatarUrl={user.avatarUrl} />;

For an extra layer, Next.js supports React's experimental taintObjectReference and taintUniqueValue APIs (enable experimental.taint in next.config.js) to make passing a tainted object or value to the client a hard error. But taint is a backstop: React's own docs warn that it only protects an object passed through unchanged — clone it, spread it, or destructure a field out of it and the derived value is no longer tainted. The real fix is a data layer that never lets the secret into the component in the first place.

How do I check my whole app at once?

Run the three greps above before every deploy. They take thirty seconds and catch the obvious cases. But greps miss the import-graph leaks (#2) and the prop-shape leaks (#3) — those require following references across files, which is exactly where manual review breaks down on a busy team.

That's the gap GuardLayer closes: it scans your repo on every push, traces secrets across the "use client" boundary and into RSC props, and posts the finding — with the fix — as a PR comment plus a merge-gate Check Run, before the leak ships. The same engine flags hardcoded API keys in Next.js source and 18 other Next.js + Supabase issues. Run a free scan on any repo and see what's actually in your bundle.

FAQ

Is the Supabase anon key or a publishable Stripe key a leak? No. Both are designed to be public and are safe in the browser — the anon key is gated by your RLS policies, and the publishable Stripe key can't move money. The leak is when a secret key (service_role, sk_live_, a private API token) ends up client-side.

Does an unprefixed process.env.SECRET leak if I use it in a Client Component? Usually it blanks to an empty string because of Next.js's server-only protection — but only for a direct process.env read. A computed constant, a dynamic key lookup, or a hardcoded literal still leaks. Don't rely on the empty-string fallback as a boundary.

How do I actually see if a secret is in my bundle? Build the app, then grep .next/static for the secret's value or shape, and view-source on a rendered page to inspect the RSC payload in self.__next_f.push(...) tags. If it's in either place, it's public.

I passed a whole user object to a Client Component but only render the name. Is that a leak? Yes, if that object has sensitive fields. All props are serialized into the HTML regardless of what the component renders. Pass only the specific fields the component needs.

If a secret already shipped to the browser, is deleting it enough? No. Treat it as compromised and rotate the key immediately. It's in deployed bundles, browser caches, and git history. Rotation is the only reliable remediation.

Catch this before it ships — free

GuardLayer scans every push for this and 19 other Next.js + Supabase issues, with the exact fix inline.

Keep reading