25 February 20266 min read

How to keep secrets out of your frontend bundle

Anything shipped in your JavaScript bundle is public. Here is what is actually safe to expose, why API keys leak, and how to audit your own build before a key ends up on Pastebin.

Anything that ends up in your JavaScript bundle is public, full stop. To keep secrets in your frontend bundle from leaking, the rule is simple: never put a private key, service-role token, or any credential that grants write or admin access into client-side code. Frontend code runs on the user's device, so the build is downloadable, the variables are inspectable, and minification hides nothing from anyone with browser DevTools. The only credentials safe to ship are the ones designed to be public, like a public anon key protected by row-level security or a publishable payment key. Everything else belongs behind a server.

This trips up a lot of teams in India shipping their first React or Next.js app, because the local dev experience makes environment variables feel private. They are not, at least not the ones the browser can read. Here is what leaks, why, and how to check your own build.

Why secrets in your frontend bundle leak in the first place

The core misunderstanding is about where the code runs. A Node backend runs on your server, so process.env.STRIPE_SECRET never leaves the machine. But a Vite or Next.js app compiles your source into static JS files that get sent to every visitor. Any variable that the browser-side code references has to be inlined into that bundle at build time, because the browser has no other way to read it.

Build tools draw this line with a naming prefix, and that prefix is the most misunderstood thing in the whole stack:

  • Vite only exposes variables prefixed with VITE_ to client code. VITE_API_URL ends up in the bundle. SERVICE_KEY does not.
  • Next.js only exposes variables prefixed with NEXT_PUBLIC_ to the browser. Everything else stays server-side, but only if you actually read it from server code (a route handler, server component, or Worker), never from a component that hydrates on the client.
  • Create React App used REACT_APP_, and a lot of older Indian codebases still run on it. Same trap.

The leak happens in two ways. First, someone prefixes a secret with VITE_ or NEXT_PUBLIC_ to make an error go away, and now the service key is in the bundle. Second, someone imports a server-only module into a component, and the bundler quietly drags the secret across the boundary. Both compile cleanly. Neither throws an error. The key is just sitting in a .js file on your CDN, waiting.

Safe to expose vs unsafe to expose

The distinction is not "frontend vs backend." It is "what does this credential authorise." Some keys are built to be public.

Safe to ship in the bundle:

  • anon key. It is meant to be public. Its power is fully bounded by your row-level security policies. Without RLS it is dangerous; with RLS it is just an identifier.
  • Firebase web config, also designed to be public and gated by security rules.
  • Payment gateway publishable or checkout keys, for example a Cashfree or Razorpay client-side key used to open a checkout. The secret key that creates orders and issues refunds stays on your server.
  • Public API endpoints, feature flags, and analytics write keys built for client use.

Never in the bundle:

  • service-role key. It bypasses RLS entirely. This is the single most common catastrophic leak.
  • Payment gateway secret keys and webhook signing secrets.
  • Database connection strings, SMTP credentials, third-party API keys for WhatsApp BSPs, SMS, or email.
  • JWT signing secrets, OAuth client secrets, any admin or root token.
A useful test: if leaking this credential would let a stranger read other customers' data, send messages on your behalf, or move money, it must never touch the browser.

RLS is what makes a public key safe

Row-level security is the reason a public anon key can be exposed at all. RLS pushes authorisation into the database itself. A policy like "a user can only select rows where user_id = auth.uid()" means that even though every visitor holds the anon key, the database refuses to return data that does not belong to the authenticated caller.

The failure mode is shipping the anon key with RLS disabled, or with a lazy policy like using (true) that allows everything. Now the public key is an open door to your entire table. Whatever backend you use, treat every table as RLS-on by default and write explicit policies. Test them by querying with the anon key from outside your app and confirming you get back only what you should.

Server-side proxying for everything else

For credentials that genuinely cannot be public, such as your WhatsApp send key, your payment secret, or a third-party API that has no public tier, the pattern is a thin server proxy. The browser calls your endpoint, your server holds the secret and forwards the request, and the response comes back. The secret never enters the bundle because the code that uses it never ships to the browser.

On our stack this is a server-side function holding the secret in a managed secrets store, with the browser sending a short-lived user JWT. The Worker verifies the caller, then talks to the upstream API with the real key. The same shape works with a Next.js route handler, an Express endpoint, or a serverless edge function. The principle is identical: the secret lives where the user cannot reach it.

One detail teams miss: a proxy is not just secret-hiding, it is also your rate limit and abuse boundary. Because the browser hits your endpoint instead of the upstream directly, you can throttle, log, and cut off a misbehaving client. A leaked key has no such control.

How to audit your own bundle

Do not assume. Check. Your production build is just text files, and you can search them.

  1. Build for production, then grep the output. Run your real build (npm run build or pnpm build) and search the output directory: dist/ for Vite, .next/static/ or the export folder for Next.js. Grep for known secret fragments: the first few characters of your service key, service_role, BEGIN PRIVATE KEY, sk_live, postgres://. If anything matches, you have a leak.
  2. Search source for bad prefixes. Grep your codebase for VITE_ or NEXT_PUBLIC_ and read every match. Each one is a deliberate decision to publish that value. Make sure none of them are secrets that crept in.
  3. Open DevTools on the live site. Network tab, look at the loaded JS files, use the Sources panel search (Cmd/Ctrl+Shift+F) across all scripts. This is exactly what an attacker does, so do it first.
  4. Add it to CI. A simple grep step that fails the build if forbidden strings appear in the output stops the leak before it deploys. Tools like gitleaks catch secrets in commits, but a post-build grep catches the bundle specifically.
  5. Check your git history too. A secret that was in .env and got committed once is still in history even after you delete it. If you find one, rotate the key. Do not just remove the file.

If you do find a leaked secret, rotation is non-negotiable. The bundle has been downloaded, cached, and possibly scraped. Removing it from the next deploy does nothing for the copies already out there. Revoke the old credential, issue a new one, and keep the new one server-side.

Make it a habit, not a fire drill

The teams that never have this problem bake the check into their pipeline so a human never has to remember it. That is also how we run our own builds at BotBrained: secrets live in Worker-side stores, public keys are gated by RLS, and the deploy fails loudly if a forbidden string shows up in the output. If you want this discipline wired into your own frontend pipeline without rebuilding it from scratch, vybckr is built around exactly that kind of build-time guardrail.

Treat your bundle as a public document, because it is. Once you internalise that, the rest of these rules stop feeling like overhead and start feeling obvious.