1.The secret key ships in the app's JavaScript
- Where
src/lib/supabaseAdmin.ts:7builds an admin client fromVITE_SUPABASE_SERVICE_ROLE_KEY, which.env:4sets. Vite builds everyVITE_value into the files it serves. The admin page,src/pages/Admin.tsx:14, uses it to list users.- Evidence
Every key in the built JavaScript: found by grep, decoded by the reproduction script Found with: grep -ohE 'eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+' dist/assets/*.js | sort -u Then, for each key: the file it's in, and its payload, base64-decoded: index-BncN2ysr.js: {"iss":"supabase-demo","role":"anon","exp":1983812996} index-BncN2ysr.js: {"iss":"supabase-demo","role":"service_role","exp":1983812996}With the service-role key from the bundle: every account (the count and the first of three shown) $ curl -X GET 'http://127.0.0.1:55321/auth/v1/admin/users?per_page=100' \ -H 'apikey: <service-role key>' \ -H 'Authorization: Bearer <service-role key>' HTTP 200 { "total": 7, "first_three": [ { "email": "[email protected]", ...- What an attacker gets
- Everything. The service-role key skips row level security on every table and works the admin API. With it we listed all 7 accounts and read the PDF service's key, which no user can see. The same key can delete accounts and rewrite any table. We didn't make those calls.
- The fix
- Move the admin page's calls into an Edge Function that checks the caller is an admin before it uses the secret key. Delete
src/lib/supabaseAdmin.tsand the.envline, and rebuild. Then retire the leaked key, as Supabase's rotation guide sets out. Move the browser to a new publishable key and the functions to a new secret key. Deactivate the legacy keys, which retires the old anon key too. Then revoke the legacy JWT secret. The functions read the new keys fromSUPABASE_SECRET_KEYSandSUPABASE_PUBLISHABLE_KEYS, and fall back to the legacy variables, so they work on both sides of the switch.supabase/functions/_shared/keys.ts and admin-users/index.ts (excerpts) // _shared/keys.ts function keyFrom(variable: string, legacy: string): string { const keys = Deno.env.get(variable); if (keys) { return (JSON.parse(keys) as Record<string, string>)["default"]; } return Deno.env.get(legacy)!; } export const secretKey = () => keyFrom("SUPABASE_SECRET_KEYS", "SUPABASE_SERVICE_ROLE_KEY"); // admin-users/index.ts const { data: isAdmin, error } = await asCaller.rpc("is_admin"); if (error || isAdmin !== true) { return json({ error: "Admins only" }, 403); } const admin = createClient(SUPABASE_URL, secretKey()); - After the fix
- The rebuilt bundle holds only the anon key, and a new CI check fails any build with a secret key in it: it fails today's build and passes the fixed one. The admin page still lists users for an admin, and the function refuses a client with HTTP 403. The move to the new keys happens on the hosted project, so it wasn't tested here.