THE SUPABASE SERVICE-ROLE KEY IN YOUR FRONTEND BUNDLE
There is a key called the anon key, which is fine to ship to the browser. There is another key called the service-role key, which is the database admin key. AI generators keep shipping the second one. Here's why, and here is the live URL where you can watch it happen.
The scenario referenced below runs on gapbench.vibe-eval.com — a public security benchmark we operate for scanner calibration. The client engagement that originally surfaced this pattern is anonymized; the gapbench scenario is the reproducible equivalent. You can hit the URL right now and watch the bug happen.
So here’s the thing about Supabase keys
Supabase gives you two keys when you create a project. The dashboard puts them right next to each other. They look almost identical — both are long JWT strings starting with eyJ, both have the same project URL, both feel like things you copy and paste once and forget.
One of them is fine to put in the browser. The other one is the database admin key.
I cannot tell you how many times I have opened DevTools on an app, hit view-source, and watched both of those keys come down in the JavaScript bundle. It happens on Lovable apps. It happens on Bolt apps. It happens on Cursor-generated apps. It happens enough that we built a scanner for it and it found enough customers to justify the build.
The reason it keeps happening is not that builders are careless. It is that the AI is helpful in exactly the wrong way.
The autocomplete that costs you everything
Picture the scene. You are using Lovable, or Cursor, or any AI-codegen tool. You ask it to add an admin dashboard. The AI writes the dashboard. It tries to query a users table. Row Level Security blocks it, because RLS is doing its job — the anon key shouldn’t be reading admin-only data.
The AI sees an error. The AI does not say “let’s set up an admin role and a policy that grants it access.” The AI knows there is another key in your .env file that doesn’t have this problem. So it writes:
const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY // <-- here
)
The error goes away. The dashboard works. You ship.
What you have actually shipped is a database admin credential to every browser that loads your app. Not “a credential that lets users do admin things if they are logged in as admin.” A credential that bypasses Row Level Security entirely, for everyone. Anyone who opens DevTools, copies the key, and points the Supabase JS client at your URL can read every row in every table you own. They can write too. They can drop tables, depending on Postgres permissions.
If you go look at https://gapbench.vibe-eval.com/site/supabase-clone/ right now — open it in a browser, view source — you will see exactly this pattern. The key is in the bundle. The key is a JWT. Decode it on jwt.io. The role claim says service_role. That is the bug.
The clean version of the same thing is at https://gapbench.vibe-eval.com/site/ref-rls/. Same Supabase, same Next.js, same UI shape. No service-role key in the bundle. RLS configured. That’s what good looks like.
Why this isn’t just “the AI made a mistake”
There’s a temptation to write this off as the AI being dumb. It isn’t. The AI did exactly what you’d do at 2am with a deadline — it picked the path that made the error stop. The mistake is upstream: the env var is named in a way that makes it look bundleable, the framework happily bundles anything prefixed with VITE_ or NEXT_PUBLIC_ without warning, and the cost of getting it wrong only shows up in a later scan.
The deeper failure is that there is no friction. Putting the wrong key in the bundle should be loud. It is silent. The build succeeds. The deploy succeeds. The app works. The dashboard you just shipped will function correctly for months, even years, until someone — researcher, attacker, or auditor — opens DevTools.
This is the failure mode of AI codegen in a single sentence: it removes the friction that used to make you stop and think. The wrong choice is now as fast as the right one.
A specific incident
Anonymized version of one we found. A founder built a Lovable app, shipped it, picked up a few hundred users in the first month. He ran VibeEval against it because the trial was free and the idea of “let’s just check” felt cheap. Eight findings came back. Seven were standard — a missing CSP, a permissive CORS rule, a couple of rate-limit gaps. The eighth was the service-role key in the bundle.
The way the bug had landed in his codebase: he had asked Lovable to add a “send weekly digest email” feature. The feature needed to read every user’s recent activity, summarize it, and email it. Reading every user’s activity violates RLS by definition — RLS only lets users read their own. The AI’s resolution was to switch the Supabase client to use the service-role key inside the digest function. Sensible, except the function was a Next.js API route, and the API route was bundled with the rest of the frontend, and Lovable’s build pipeline didn’t separate the env vars by route. The key landed in the production JavaScript bundle for every page.
He had been live for 41 days when we found it. He rotated the key the same day, moved the digest function to a Supabase Edge Function with the new key never reaching the client, and re-ran the scan to confirm. Nothing in the post-mortem suggested anyone else had found the leak first — but he had no way to be sure, because the key didn’t get logged when used. The scariest part was the gap between deploy and detection.
Wrong fix vs right fix
The instinct, when you find this, is to “rotate the key and move the call to a server function.” That’s correct as far as it goes. The trap is the server function part. Several patterns look like they fix the bug but don’t:
// WRONG: still ships to the client
// app/api/admin/users.ts is rendered on the server
// but Next.js may include this file's imports in client bundles
// if it's imported anywhere else in the tree
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(URL, process.env.SUPABASE_SERVICE_ROLE_KEY)
// WRONG: env var is server-only but the call is from a route
// that gets pre-rendered with the env var inlined
export const config = { runtime: 'edge' } // OK, but not enough
// RIGHT: env var server-only + call inside a Supabase Edge Function
// Function runs in Supabase's environment with its own secret manager
// The Next.js bundle never sees the key
// supabase/functions/digest/index.ts
import { createClient } from 'jsr:@supabase/supabase-js@2'
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
The pattern that works: the service-role key never lives in any environment that produces a public artifact. Supabase Edge Functions, Cloudflare Workers with separated env, AWS Lambda with separate IAM — each of those has the property. Next.js API routes do not, by themselves, unless you carefully audit the import graph.
How the same bug looks on other stacks
Not Supabase-specific. The pattern repeats wherever a backend SDK has a “client safe” key and an “admin” key:
- Firebase.
firebaseConfig(public web key) versus the Admin SDK service-account JSON. We see the service-account JSON inlined into the bundle by AI codegen that importsfirebase-adminfrom a Next.js component because the model wanted to “create a user from the signup form.” - Stripe. Publishable key (
pk_live_...) versus secret key (sk_live_...). Same shape. Stripe at least screams in dev when you use the secret key from the browser, which catches some cases. Not all. - Algolia / Elasticsearch / Pinecone. Search-only API key versus admin API key. Search-only is browser-safe; admin grants write and schema-change. AI codegen swaps them when search-only “doesn’t work for this use case.”
- OpenAI. No formal “browser-safe” key — every OpenAI key is server-only. AI codegen still embeds them in client code regularly because the example in the OpenAI docs uses fetch() and the model doesn’t always keep track of which side the fetch is running on.
The mitigation is the same across all of them: only the explicitly-public key reaches the bundle, and you have a CI check that fails the build if anything else does.
How we catch it
You don’t need a fancy scanner for this — although we sell one — you need a scanner that fetches your deployed JavaScript bundle, scans for JWT-shaped strings, decodes them, and reads the role claim. That is the whole detection. We run it as part of vibe-code-scanner. The token-leak-specific tool at /token-leak-checker/ does just this and produces a report.
The detection internals, in case you want to write your own: fetch every <script src> from the rendered HTML. For each script, scan for the regex eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+ — the JWT-shape pattern. For each match, base64-decode the middle segment, parse as JSON, read the role claim. If role === "service_role", finding. Repeat for firebase admin patterns, sk_live_ Stripe patterns, AWS access keys (AKIA... for IAM, ASIA... for STS), and so on. The whole token-leak detection is ~50 lines of Python. The reason most scanners don’t run it is that they don’t fetch deployed bundles at all — they read source.
Static scanners — Snyk, Semgrep, the GitHub secret-scanning push protection — will flag the env var in source. That helps if the developer notices. It does not help if the developer commits the file or if the AI quietly adds the env var to .env.local. The only place this bug can be definitively detected is in the deployed bundle, after the build has run, against the live URL. Heuristic scanners that only read source code don’t see the bundle.
This is half the reason we built gapbench. A static scanner cannot calibrate against https://gapbench.vibe-eval.com/site/supabase-clone/ because there is no source code for it to look at — there is a deployed surface. Either you scan the surface or you miss the bug.
What you should actually do
If you suspect you have this bug, the fix order matters and the rotation order matters more.
First, rotate the service-role key in the Supabase dashboard. Do this before you fix the code. The leaked key is already public — every minute it stays valid is a minute someone can read your database. Rotation invalidates it.
Second, find every callsite that used the service-role key. The AI probably used it for any operation that touched RLS-protected tables. Each of those needs to move to a Supabase Edge Function or a Next.js API route running on the server, where the new service-role key never reaches the client.
Third, verify the new bundle does not contain the new key. Same scan, different key. If the key still leaks, the build pipeline is wrong — usually because the env var is still prefixed VITE_ or NEXT_PUBLIC_ or because someone hardcoded it.
Fourth, write an RLS policy for the admin operation that no longer uses the service-role key. The pattern is: a Postgres role for “admin,” a function that checks the user’s claim against that role, and an RLS policy that grants the function access. The Supabase docs cover this in the auth section.
Fifth — and this is the one most teams skip — write a CI check that fails the build if any JWT in the bundle has a service_role claim. It is a one-liner: dump the bundle, grep for eyJ, decode each match, fail on role: "service_role". We publish a version of this in the Supabase RLS Checker tooling.
CWE / OWASP
- CWE-200 — Information Exposure
- CWE-798 — Hardcoded Credentials
- CWE-522 — Insufficiently Protected Credentials
- OWASP API Security Top 10 — API2:2023 Broken Authentication, API5:2023 Broken Function-Level Authorization
Reproduce it yourself
Live scenarios on the gapbench benchmark:
- Vulnerable: https://gapbench.vibe-eval.com/site/supabase-clone/
- Vulnerable variant: https://gapbench.vibe-eval.com/site/config-leak/
- Vulnerable variant: https://gapbench.vibe-eval.com/site/sentry-dsn-leak/
- Clean control: https://gapbench.vibe-eval.com/site/ref-rls/
Run VibeEval against the vulnerable URLs and you should see the leak finding fire. Run it against ref-rls and you should see nothing. If your scan against your own app looks more like the first set than the second, you have the bug.
Related reading
- Data study: Where Vibe Coders Leak Their Keys — 2026 Frontend Secrets Report
- Data study: Supabase RLS in the Wild — 2026 Misconfiguration Atlas
- Tool: Supabase RLS Checker
- Tool: Token Leak Checker
- Safety review: Is Lovable Safe?
RUN IT YOURSELF
Each scenario below is live on the public benchmark. The commands are copy-paste ready. Outputs may evolve as we tune the scenarios; the bug stays.
curl -s https://gapbench.vibe-eval.com/site/supabase-clone/ | grep -oE "eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+" | head -2
curl -s https://gapbench.vibe-eval.com/site/ref-rls/ | grep -oE "eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+" | head -2
COMMON QUESTIONS
SCAN YOUR FRONTEND FOR EXPOSED KEYS
We hit the deployed bundle and tell you which keys leaked. 60 seconds, no setup.