All posts

Field guide

Your API key is in an environment variable and it still leaked. Here's why.

Moving a secret into an environment variable feels like the fix, and for git history it is. But an env var does nothing to keep a value out of the browser. One prefix, or one use in a client component, and your secret ships to every visitor. Here is the trap and how to actually use a secret key.

Last updated

This one surprises people, because it feels like they did the right thing. You had a key hardcoded, you moved it into an environment variable, you deployed, and it turned up in your bundle anyway. The reason is that an environment variable and a browser-exposed value are two different things, and the env var only fixes one of them.

What an environment variable actually does

An environment variable keeps a secret out of your source code and your git history. That is real and worth doing. What it does not do is control where the value ends up when your app is built. If you reference that variable in code that runs in the browser, the build tool has to put the actual value into the client bundle so the browser can use it. The variable name is gone; the real value is right there in the shipped JavaScript.

Want to see exactly what Prbl flags? Watch it scan a demo app, no repo or account needed.

See a live scan →

The two ways it ships to the browser

There are two common ways an env-var secret still reaches every visitor:

# 1. The public prefix bakes the value into the client bundle
NEXT_PUBLIC_STRIPE_SECRET=sk_live_...      # Next.js
VITE_API_SECRET=sk_live_...                # Vite

# 2. Using the variable in a client component / browser code
// components/Chat.tsx  ("use client")
const res = await fetch("https://api.example.com", {
  headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_API_KEY}` },
});

The NEXT_PUBLIC and VITE prefixes exist to expose a value to the browser on purpose, which is correct for public-by-design values and wrong for secrets. And any variable referenced in a client component gets bundled the same way. Either path publishes the value; see client-side vs server-side secrets for the full picture.

The fix: keep secret keys on the server

A secret key belongs only in code that runs on the server, a route handler, a server action, or your backend, with no public prefix. When the browser needs something that requires the key, it calls your server, the server uses the key, and only the result comes back.

// app/api/quote/route.ts  — runs on the server only
export async function POST(req: Request) {
  const key = process.env.STRIPE_SECRET_KEY;   // no NEXT_PUBLIC prefix
  const result = await callStripe(key, await req.json());
  return Response.json(result);                 // client gets the result, not the key
}

The key value never enters the bundle, so there is nothing for a visitor to read. Publishable and public-by-design values, a Stripe publishable key, a Supabase anon key, a Firebase config, are the exception and can be exposed; a secret that grants privileged access cannot.

Frequently asked questions

I put my key in .env, so why is it in the browser?

Because an environment variable solves a different problem than the one you think. It keeps the value out of your source code and git history. It does nothing about where the value ends up at build time. If you reference that variable in front-end code, or prefix it with NEXT_PUBLIC or VITE, the build tool inlines the actual value into the JavaScript bundle that ships to every visitor. The variable was never the protection; where you use it is.

What does the NEXT_PUBLIC or VITE prefix actually do?

It tells the build tool to expose that variable to the browser by baking its value into the client bundle. That is the intended behavior, for values that are safe to be public. The mistake is using the prefix on a real secret because the app could not otherwise see it. The prefix does not encrypt or hide anything; it publishes. Anything with that prefix is readable by anyone who opens the page.

How do I use a secret key then?

Keep it on the server. Reference it only in server-side code, a route handler, a server action, or your backend, and never prefix it for the browser. When the client needs data that requires the key, it calls your server, and your server uses the key and returns only the result. The key value never reaches the browser, so there is nothing to leak.

Some keys are supposed to be public, right?

Yes. A Stripe publishable key, a Supabase anon key, or a Firebase config are designed to be in the browser, and prefixing those is correct. The rule is simple: publishable and public-by-design values can be exposed; secret keys, the ones that grant privileged access, cannot. If a key would let someone act as your server, it stays server-side.

Check what your bundle is shipping

The tricky part is that this looks fine in your editor; the value only appears once the app is built. A scan reads what your deployed app actually serves and flags a secret key that made it into the browser, prefix or not. Run a free scan and see what your bundle is exposing.

Prbl scans your live app or your codebase for exactly the kinds of issues above.

Your API Key Is in an Environment Variable and It Still Leaked. Here's Why: Prbl