Not every key in your Supabase project is a secret. That is the detail an AI-built app gets wrong in the most expensive direction.
The anon (publishable) key is designed to sit in the browser. It identifies your project, and Row Level Security is what stands between it and your data. Shipping it to the client is normal and expected.
The service-role key is the opposite. It bypasses Row Level Security entirely and acts as the owner of your data. It is a server-only secret. The moment it lands in a browser bundle, anyone who opens your site can read every row, forge an entitlement, and grant themselves the thing you sell.
So "a Supabase key is in my JavaScript" is not the question. The question is which key—and in a vibe-coded Next.js app, the dangerous one gets there through a mechanism that looks harmless.
How a secret crosses into the browser
Next.js has one rule for what reaches the client: an environment variable is exposed to the browser if, and only if, its name starts with NEXT_PUBLIC_. That prefix is a publishing decision, not a naming convention.
The leak arrives in a few ordinary ways:
- A generator or a hurried fix names the server key
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY, because the anon key next to it also had the prefix and copying it "worked." - A secret is read inside a Client Component—a file that runs in the browser—so its value is inlined into the bundle at build time.
- A key is hardcoded as a string literal in shared code that ships to the client.
In every case the build does exactly what you told it to. It writes the secret into a static JavaScript file and serves that file to every visitor. There is no error. The app works. The key is public.
The useful launch question is concrete:
If I open my site in a private window and read the JavaScript it serves, is any server-only secret in there?
Separate "publishable" from "secret" on purpose
Sort every value your app uses into two lists, and let the prefix follow the list—not the other way around.
Publishable, safe in the browser: the Supabase anon key, your public site URL, analytics IDs, publishable payment keys (for example a Stripe pk_ key). These may carry NEXT_PUBLIC_.
Server-only secrets, never in the browser: the Supabase service-role key, webhook signing secrets, database URLs, and any private API key (for example a Stripe sk_ key or a Lemon Squeezy API key). These must never carry NEXT_PUBLIC_, and must only be read in server code—Route Handlers, Server Actions, or server-side data functions.
The test is not "does the name look scary." It is: if this value were printed on your home page, would you have to rotate it? If yes, it is server-only.
Run the exposure check against your own build
A grep of your source is a start. A grep of your build output is the proof, because the build is what the browser actually receives.
First, build the client the way production does, then search the shipped bundle for the secrets themselves—not their names:
- Run your production build so the client assets are generated.
- Search the generated client output for the literal value of each server-only secret: the service-role key, webhook secrets, private API keys.
- The passing result is zero matches. Any match means that secret shipped.
Then close the door that let it happen:
- List every
NEXT_PUBLIC_variable. Confirm each one is genuinely publishable by the test above. - Confirm each server-only secret is read only in server code, never in a Client Component.
One rule has no exception. If a service-role key or other secret was ever built into a client bundle—even in a preview deploy, even for a minute—treat it as compromised and rotate it. A key that reached the browser cannot be un-shipped; it may sit in a CDN cache, a fork, or someone's open tab. Removing the prefix fixes the next build, not the leak that already happened.
Keep the build-output search in your launch suite. Run it whenever you add an environment variable or move code between server and client.
Know what green does—and does not—mean
Zero secrets in one build is evidence for that build, on that day. It is not a guarantee that the next commit keeps the line.
It does not audit third-party scripts, and it does not prove the anon key is safe on its own—the anon key is only as safe as the Row Level Security behind it. A publishable key in the browser is fine precisely because a policy, not secrecy, protects the data. Ship the anon key; test the policy.
The goal is not to call the app "secure." The goal is to preserve a falsifiable statement:
The client bundle my site serves contains no value I would have to rotate if a stranger read it.
Your prototype working is the start. A build that ships your publishable keys and none of your secrets is evidence you can sell behind.
Run the broader launch-readiness checklist
Sources: Next.js environment variables and Supabase API keys.
Test this on your own app before you sell it.