The short answer
If a React bundle can call OpenAI, Stripe, or Supabase with a privileged key, the internet already has that key.
  • Treat the bundle as public. Crawlers read .js the same way your browser does.
  • Move the call. Put model, payment, and admin work behind Next.js Route Handlers or Edge Functions.
  • Use the right key. Stripe’s key docs and Supabase’s API key guide both say secret keys stay on the server.
  • Harden, then rotate. IndiaNIC security hardening starts by searching the built files, not the source you meant to ship.

If your React bundle can talk to OpenAI, who else already can?

That is not a riddle. It is the first question I ask when a vibe-coded app “just works” in the browser. If the client holds the key, the client is the API. Anyone who can download the JavaScript can spend your money, mint a charge, or read a table.

A Pune founder forwarded me a gist late one night. A stranger had pasted three strings from his production bundle: an OpenAI secret, a Stripe secret, and a Supabase service_role. The product still looked fine. The keys did not.

That night changed the order of work. We stopped adding features. We started treating the built JavaScript as a public document.

What changed when the bundle became the leak

Vibe-coding tools are generous. A prompt that says “connect OpenAI” will drop NEXT_PUBLIC_OPENAI_API_KEY into the client because that is the fastest path to a streaming chat. The same pattern shows up with Stripe and with Supabase when the generator wants the browser to “just talk to the backend.”

According to the OWASP note on hard-coded keys, a secret compiled into a client is not a secret. It is a default password with extra steps. Crawlers do not need a zero-day. They fetch /_next/static/chunks and search for sk-, rk_live, and service_role.

I have watched this on an Ahmedabad rescue as well. The repo looked clean. The .env file was not committed. The built chunk still held the key because the build inlined process.env.NEXT_PUBLIC_*. A walkthrough I ran, sitting with the IndiaNIC hardening team, showed the Stripe secret in a vendor file the founder had never opened.

Documentation from Stripe is explicit: secret keys belong on a server you control. Documentation from Supabase is equally explicit: the secret or service_role key bypasses row security. If either string is in the bundle, you do not have a frontend bug. You have an open admin door.

After-hours workstation with a half-closed glowing laptop and a USB key on the desk
If the key is in the bundle, the internet already has a copy.

Three rules that actually keep a key off the internet

I use three rules on every IndiaNIC security hardening pass. If any one is missing, the other two will not save you for long.

RuleWhat it meansWhere it lives
Browser is publicAnything shipped to the client can be copiedPublishable keys only
Calls move server-sideThe browser sends intent, not credentialsRoute Handlers, Edge Functions
Assume leak, then prove otherwiseScan the build, rotate, lock headersSecret scanning, CSP
A key in the client is not a configuration mistake. It is a published password.

Rule one is boring on purpose. The browser is a hostile computer you do not own. Publishable Stripe keys and Supabase publishable keys exist because those products designed for that fact. OpenAI secret keys did not.

Rule two is the move. The client posts a prompt or a checkout intent to a route you own. The route reads the secret from the server environment, talks to the provider, and returns only what the user is allowed to see. Next.js documents this path as Route Handlers. Supabase documents the same idea as Edge Functions.

Rule three is the rehearsal. Search the built files. Turn on GitHub secret scanning. Add a Content Security Policy so a stolen page cannot load a surprise script that forwards the session. Data from those docs is consistent: prevent the next leak while you clean the last one.

Why do AI-generated React apps put secrets in the client?

AI-generated React apps put secrets in the client because the fastest prompt path is a browser call that “just works,” and generators treat NEXT_PUBLIC_ as a convenience switch. The model is not protecting your bill. It is finishing the demo. Anything that must stay secret has to live in a server route you write on purpose, not in a component the tool emitted.

I do not blame the tool. I blame the habit of shipping the first green preview. A preview that holds a secret is already a production incident with a nicer URL.

What does IndiaNIC security hardening change first?

IndiaNIC security hardening changes the call path first: we rip privileged keys out of the client, move OpenAI, Stripe, and Supabase admin work to Route Handlers or Edge Functions, then rotate every key that ever touched the bundle. Only after the build is clean do we add scanning, CSP, and a short runbook for the next leak. Features wait.

On the Pune product we did not rewrite the chat UI. We rewrote the one function that held the key. The UI kept streaming. The secret stopped travelling with every page load.

Search the built files, not only the repo. A clean .env does not mean a clean chunk. Until grep on .next/static is quiet, assume the internet already copied you.

Implementation steps I actually run

Do this in order. Skipping ahead is how the old key stays in an old chunk on a CDN.

  1. Build the app. Search the output for sk-, rk_live, service_role, and sb_secret.
  2. Rotate every match. The old key is public even if you delete the line today.
  3. Move OpenAI calls to a Route Handler. The client sends the user message. The server holds OPENAI_API_KEY.
  4. Move Stripe secret work to the server. The browser may hold the publishable key. It may not hold the secret.
  5. Give the client only the Supabase publishable key. Keep the secret key inside an Edge Function that enforces auth.
  6. Turn on secret scanning and a CSP that names your own script sources.
  7. Purge the CDN. Old chunks are still downloadable until you do.

export async function POST(req) { const key = process.env.OPENAI_API_KEY; }

That line belongs in a server file. If you can see it in DevTools, you are not done. I have collected more of these notes on the IndiaNIC blog for teams who want the longer hardening list.

What success looks like

Success is a boring build. The production JavaScript has no secret prefixes. Stripe and Supabase calls that need privilege go through a route that checks a session. A crawler that downloads your chunks gets a chat UI, not a credit line.

Measure it. After each deploy, grep the output. After each rotate, confirm the old key is dead at the provider. After each week, check the provider dashboard for calls that did not come from your server IP or your function logs.

If you cannot explain, in one sentence, which key is allowed in the browser, you are not ready to ship the next prompt feature. Write that sentence. Then make the bundle agree with it.

I still remember a Saturday where we grepped a production bundle and found three live keys in under four minutes. The scrape was not clever. It was strings and a regex. That is the operational detail that changed how we review every AI-generated React app we touch: if a secret can survive a minifier, it is already public.

What we now do on day one of a hardening sprint is download the client bundle from the preview URL, run the same grep a crawler would run, and paste the hits into a locked channel. No theatre. If the key is there, we rotate it before we refactor anything else. Speed matters more than elegance when the internet is already reading the file.

Frequently asked questions

Is a NEXT_PUBLIC_ key ever safe for OpenAI or Stripe?

A NEXT_PUBLIC_ key is never safe for OpenAI secrets or Stripe secrets, because Next.js inlines those values into the browser bundle on purpose. Only publishable Stripe keys, and Supabase publishable keys, belong in that prefix. If a secret needs the word secret in its name, it needs a server route, not a public env var.

If the generator added NEXT_PUBLIC_ to make the demo stream, that is the first line to delete.

Do Edge Functions hide keys from the browser?

Edge Functions hide keys from the browser only when the secret stays in the function environment and the client never receives it. The browser may call your function. It may not download the function’s env. If you return the key, log the key, or put the key in a response header, the function is just a longer route to the same leak.

Return data the user is allowed to see. Return nothing that could spend money.

What should I do first if a key is already in the bundle?

If a key is already in the bundle, rotate it at the provider before you refactor anything, because crawlers and old CDN chunks may already have a copy. Then move the call to a server route, rebuild, purge the CDN, and grep the new output. Cleaning the source without rotating leaves the stolen key alive.

The 2026 lesson I keep repeating on these rescues is simple. The bundle is a publication. Publish on purpose.