Engineering5 min read
Is your API key in the browser bundle?
Secrets leak into frontend bundles constantly, and NEXT_PUBLIC_ is not the culprit people think it is. A 30-second check, and the fix that actually holds.
Every code audit we run turns up the same class of bug, and it is almost never exotic. Someone needed to call an API from a React component, the API wanted a key, and the key went into the component. It works. It ships. And from that moment the key belongs to anyone who opens DevTools.
This is worth writing down because the usual advice - "use environment variables" - is not just incomplete, it is actively misleading.
Environment variables do not make a secret secret
In Next.js, a variable prefixed with NEXT_PUBLIC_ is inlined into the
JavaScript sent to the browser. That is the documented behaviour and it is the
right behaviour: the prefix exists to mark values that are meant to be public.
The failure happens one step earlier. A developer moves a hardcoded key into
.env, sees the linter go quiet, and assumes the problem is solved. But the
code calling the third-party API still runs in the browser, so the key still has
to reach the browser, so the prefix goes on - and nothing has changed except
that the secret is now harder to find in the repo.
The rule is simpler than the tooling suggests:
If the code that uses a credential runs in the browser, the credential is public. No amount of configuration changes that.
The 30-second check
You do not need a scanner. Fetch your own production page, pull out the script chunks, and grep them for anything that looks like a key:
curl -s https://example.com/ \
| grep -oE '/_next/static/[^"]+\.js' \
| sort -u \
| while read -r chunk; do
curl -s "https://example.com$chunk" \
| grep -oE '(sk_live_[A-Za-z0-9]+|AIza[A-Za-z0-9_-]{35}|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,})' \
&& echo " ^ found in $chunk"
done
Adjust the patterns to whatever you actually use. The point is the shape of the check: look at what you shipped, not at what you wrote. Source code and build output disagree more often than people expect, and only one of them is what users download.
Run it against a page that contains a form, a map, a chat widget, an analytics call - anywhere a third-party service is involved.
What tends to turn up
In rough order of how often we see it:
- Notification webhooks. Slack, Telegram, Discord. A contact form posts directly to the messaging API so nobody has to write a backend.
- Database anon keys that are not as anonymous as assumed, because row-level security was never switched on and the key grants full table reads.
- Analytics and CRM write tokens pasted into a
<script>block. - Cloud storage credentials for a direct-upload feature.
The messaging webhook is the most common and the most instructive. It usually sits behind a form, which means the key is on the busiest page on the site.
What an attacker does with it
Not always what people imagine. A leaked notification token rarely leads to a dramatic breach - it leads to your team's channel filling with garbage, which is its own kind of outage when that channel is where sales leads land.
There is a quieter failure mode too. Several providers scan public code for their own credential formats and revoke on sight. Telegram does this. GitHub does this on behalf of dozens of partners. So the sequence is often:
- The key ships to the browser and, usually, to a public repo.
- The provider's scanner finds it.
- The key is revoked automatically.
- The feature stops working, silently, with no deploy to blame.
Debugging that from the symptom end is miserable. The form is fine, the network
tab shows a 401, and nothing in the codebase changed. The cause is a scanner
you have never heard of, acting on a commit from two years ago.
The fix
Move the call to the server. In Next.js that is a Route Handler:
// app/api/notify/route.ts
export const runtime = 'nodejs';
export async function POST(req: Request) {
const token = process.env.NOTIFY_TOKEN; // no NEXT_PUBLIC_ prefix
if (!token) {
return Response.json({ error: 'Not configured' }, { status: 501 });
}
const body = await req.json();
// validate body here, then call the third-party API
}
The browser now talks to your own endpoint, and the credential never leaves the server. That is the whole change.
But moving the call is only half the value. The old design had one accidental property worth noticing: because the browser talked straight to the third party, there was nowhere to put any logic. Once the request passes through your own route, you get somewhere to stand:
- Validation. Reject junk before it reaches the third party.
- Rate limiting. Refuse the sixth submission from one address in a minute.
- A honeypot field. Hidden from users, filled by bots, silently discarded.
- Escaping. If the payload becomes a formatted message, escape user input, or a name containing markup will break the message - or inject into it.
- Graceful failure. Save the submission first, notify second. If the notification service is down, the lead is still captured rather than lost.
None of that is possible when the browser holds the key.
Order of operations if you find something
Rotate first. Not audit-first, not commit-first - the moment a credential is known to be public, its lifetime is the only thing that matters.
Then, in order:
- Rotate the credential at the provider.
- Deploy the server-side path with the new value, so the feature works again.
- Verify the bundle with the check above - a stale build can still be serving the old key from a CDN.
- Then decide about git history. Usually you should not bother: rewriting history is disruptive, and a rotated credential is inert. Purge only if the value cannot be rotated.
That last point surprises people. History rewriting feels like the thorough option, but it is the least urgent step and the one most likely to break other people's clones. A dead key in an old commit is a curiosity, not a risk.
Make it a build step
Checks that live in someone's head stop running. If the grep above found nothing, that is a good moment to wire it into CI so it keeps finding nothing on purpose - fail the build when a bundle matches a credential pattern.
It costs a few seconds per build and it catches the thing that is otherwise only caught by a stranger, a scanner, or a bill.