Your browser bundle is leaking secrets — and you shipped it
You meant to keep a key on the server. Next.js put it in the JavaScript it sends to every visitor. The fix is two lines, but you have to know to look — because nothing warns you when it happens.
September 2026 · Zoltra · No account needed to use this fix
Here is a thing that happens quietly, to almost everyone who builds on Next.js at some point. You create an env variable. You prefix it with NEXT_PUBLIC_ because that is what the docs told you to do when you needed it in the browser. You move on. Weeks later that key is sitting in plain text inside/_next/static/chunks/pages/_app.js, readable by anyone who can run curl.
It is not a bug. It is how the framework works. Anything named NEXT_PUBLIC_ is intentionally baked into the client bundle at build time so the browser can use it. The problem is that the line between server-only and browser-safe feels fuzzy when you are moving quickly. You pass a Supabase anon key into a server component, refactor a helper into a client hook, forget to rename the variable — and now a secret is client-side. Nothing errors. No warning in the build. Your app still works. You just made your database reachable from outside without noticing.
I used to think of this as a minor hygiene thing you could clean up later. It is not. A public key in your bundle is an invitation, and the guests have changed. It used to be that only a motivated human would dig through your chunks. Now an agent can fetch your bundle, grep it for NEXT_PUBLIC_SUPABASE, and try the key against your PostgREST endpoint — all without a human telling it to. The cost of finding that leak dropped to near zero. Your tolerance for leaving it around should drop with it.
What actually leaks, and why it matters
The classic case is NEXT_PUBLIC_SUPABASE_ANON_KEY. You needed it to initialize the Supabase client in a component. So you put it in .env.local with the prefix, ran next build, and it got inlined as a string literal in the output. Anyone who loads your site gets it. View source is not even required — a single request does it:
# You can do this against your own site right now curl -s https://example.com/_next/static/chunks/pages/_app.js \ | grep -o "NEXT_PUBLIC_SUPABASE_ANON_KEY[^\"]*"
That key by itself does not mean your data is exposed. Supabase anon keys are meant to be used from the browser in some setups. What matters is what that key is allowed to do on your database. If your tables have proper Row Level Security policies and your anon role cannot read what it should not, the key leaking is annoying, not catastrophic. But a surprising number of tables ship without RLS at all — which is the next post in this series. When a leaked anon key meets a table without RLS, the swarm does not need to be clever. It just asks.
Other things that end up behind the same prefix and surprise people:
- Analytics write keys, feature flag tokens, and third party client IDs that are safe to expose — mixed in the same
.envfile with things that are not. Once you normalize putting secrets there with a prefix, everything drifts there. - Internal API URLs and bucket names that reveal infrastructure names. Not a secret by itself, but useful information for someone mapping your surface.
- Service-adjacent keys that someone added as
NEXT_PUBLIC_during a quick prototype and never moved back. These are the ones that actually hurt.
The tell that you have this problem is usually silence. The app works, tests pass, nobody complains. There is no runtime error that says “you are shipping a server secret to the browser.” You have to go looking.
And idk — that silence is what makes this one annoying. Most exposures at least show up as a weird log line or a failed permission check somewhere. This one does not. It is a build artifact doing exactly what the framework documented, just not where you expected. I have seen teams grep their source for secrets, find nothing, and feel done — while the secret is sitting in the output directory, in a filename hashed so you will never eyeball it. You have to check the place your users actually fetch.
One more nuance people get wrong: the anon key vs the service role key. The service role key absolutely must never ship to the browser — that one bypasses RLS and can write anything. But the anon key being public is not automatically fine either. It is public in the sense that you choose to make it public. If you leave it behind a NEXT_PUBLIC_ prefix without thinking, you did not choose. You defaulted. The difference matters. A deliberate “this key is public and the database is locked down” is a posture. An accidental “oops it is in the bundle” is just a leak you have not seen yet.
The shape of the mistake
Most env leaks are not someone intentionally publishing a secret. They are a drift. You start with a server component that reads process.env.DATABASE_URL. No problem, that stays on the server. Then you extract a helper that both the server component and a client component use. Someone imports it in the client. Next.js helpfully bundles it for the browser, and the env reference comes along. Or you rename a variable toNEXT_PUBLIC_ so one hook can use it, and now every import of that module shares the exposure.
Another common path: you followed a starter template that putNEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY in the example env. The template author meant it as a quickstart. It became your production config. Nobody revisited it after the scaffold.
I keep coming back to this detail because it reframes the fix. You are not cleaning up a one-off typo. You are putting a rule in place so the next person on your team cannot reintroduce it by accident next week. That is the whole game with env leaks: make it hard to do wrong, easy to verify.
The two-line fix
It really is two lines of intent. Rename on the server, read on the server.
# 1. Rename the variable to server-only (remove the NEXT_PUBLIC_ prefix) # .env.local # Before: NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... SUPABASE_ANON_KEY=eyJ... # 2. Read it only where the browser never runs — a Route Handler or server component # app/api/me/route.ts const key = process.env.SUPABASE_ANON_KEY; // use key server-side, return only the data the client needs
If you truly need a Supabase anon key in the browser, make that a deliberate choice, scope it to a single module, and treat it as public. The fix then is not “hide the anon key” but “make sure the database behind the anon key cannot serve what it should not.” That means RLS everywhere. We will get to that.
As someone who has cleaned this up in a few codebases, the part I keep warning people about is scope creep in the env file. You start with two safe public values. Then someone adds a Stripe publishable key. Fine. Then someone adds an analytics write key. Also fine. Then someone adds the Supabase URL next to it and it feels the same — it is just a URL, right? And then the anon key lands on the next line because it is “like the URL.” The file looks uniform. The boundary between safe-to-expose and not is invisible when every line starts with the same prefix. That is how a safe file becomes a leaking file without a single bad intent. Name the boundary, not the file.
What I do after the rename:
- Search the codebase for any remaining
NEXT_PUBLIC_that looks like a secret. If it is not a truly public value (analytics ID you are comfortable publishing, map tile key you already committed to exposing), rename it. - Add a build-time check. The simplest one is a grep that fails the build if a pattern shows up in the output:
# In CI, after next build grep -r "NEXT_PUBLIC_SUPABASE_ANON_KEY" .next 2>/dev/null \ && echo "ERROR: env key in bundle" && exit 1 echo "ok: no env key in bundle"
- Check that you did not just move the leak somewhere else. If a client component still imports a module that reads the server-only variable at the top level, some bundlers will still attempt to include it. Keep the boundary explicit: server components and Route Handlers read server env, client components receive only the derived data as props.
How to know you are done
This is the honest verification. Run it against your own deployed site, not localhost:
# Fetch the built bundle and look for the key pattern curl -s https://example.com/_next/static/chunks/pages/_app.js \ | grep -q "NEXT_PUBLIC_SUPABASE" && echo "STILL LEAKING" || echo "ok: no env in bundle" # Do the same for the Next 14 app router chunks curl -s https://example.com/_next/static/chunks/app/page-*.js 2>/dev/null \ | grep -q "SUPABASE_ANON_KEY" && echo "check app chunks too" || echo "ok"
If either prints STILL LEAKING, the rename did not reach the output. Rebuild, redeploy, and run the check again. If it says ok, you are good — for this deployment. Put the grep in CI so the next deploy cannot quietly reintroduce it.
A quick note on how I think about the “but Supabase says anon is fine” pushback. They are right that the anon key is designed to be used from the browser in some architectures. What I am pushing on is not the design but the posture. “Anon keys can be public” is true when paired with “and every table is locked with RLS and anon grants are revoked where they should be.” Without that second half, it is just “my key is public and my tables are open.” Those are very different situations, and the key being in the bundle makes it easy to confuse them.
You can still ship this fix yourself. With GitHub connected, Zoltra can also prepare the repair as a pull request, leave it for your review, and verify the live result after deployment. That matters more than it used to — because the things looking at your site now do not get tired, and they do not forget to check.
Next: Lock every table with RLS — why the env key matters less than the policy behind it, and how one query tells you if you are open.