Is Supabase anon key safe to expose? The RLS answer
Is Supabase anon key safe to expose? Yes. Supabase designs the anon key and its replacement, the publishable key, to sit in browser code and public repos. Row Level Security (RLS) protects your data. If RLS is off on an exposed table and anon has grants on it (the default on existing projects), that key opens it. The service_role key bypasses RLS and must stay on your server.
QUICK CHECKLIST
- Enable RLS on every table in an exposed schema
- Use the publishable key in browser and mobile code
- Keep the secret key in server-only env vars
- Revoke grants you don't need from anon
- Curl your own REST API with only the public key
- Run the Security Advisor and read each finding
Is Supabase anon key safe to expose? Yes, with RLS on
The Supabase docs call the publishable key (the replacement for the legacy anon key) safe to expose online, including in browser code, mobile apps and public repos. Every request to your project carries an API key. The key says what is calling, such as a web page or a server. Supabase Auth says who is calling.
Supabase is renaming its keys. The Supabase publishable key (sb_publishable_...) replaces the legacy anon key. The Supabase secret key (sb_secret_...) replaces the service_role JWT. Both systems work side by side until you disable the old ones.
Every key maps to a built-in Postgres role. Requests with the public key run as anon when nobody is signed in and as authenticated when a user is. Your policies decide what the anon and authenticated roles can touch.
| Publishable (anon) | Secret (service_role) | |
|---|---|---|
| Format | sb_publishable_... (legacy: anon JWT) | sb_secret_... (legacy: service_role JWT) |
| Row Level Security | Applies | Bypassed |
| Safe in browser code | Yes | Never |
| Where it belongs | Browser, mobile app | Server, Edge Function, cron job |
Based on the Supabase API keys docs.
What an attacker does with your anon key
An attacker opens your site, looks at the Network tab and copies your project URL and key. You can't hide either one. The docs say a publishable key doesn't protect against the browser network inspector or reverse engineering.
Then they skip your frontend and call the REST API directly at https://YOUR_PROJECT.supabase.co/rest/v1/<table>. Table names are easy to find because your app queries them by name. What comes back depends on your setup:
- RLS off, default grants in place: they read, insert, update and delete every row.
- RLS on, no policies: the public key gets nothing.
- A policy like
to anon using (true): every visitor reads every row it covers. Fine for a public feed, a leak for anything else. - A policy that only says
to authenticated using (true): anyone who can sign up gets access. Compareauth.uid()to an owner column instead.
On existing projects, a new table in public starts with every privilege granted to anon, authenticated and service_role. Adding policies doesn't take those grants back. Step 2 below covers the revoke.
Views bypass RLS by default, so a view over a protected table can hand out rows your policies hold back. See Supabase security definer view for the fix and Supabase RLS mistakes for the policy errors that show up most.
Where AI tools put the key wrong, and the fix
When an RLS error shows up in the browser, an AI tool often swaps in the legacy service_role key to make it vanish. The error goes away because that key skips RLS. Your data is now open to anyone who can read your JavaScript bundle.
A new sb_secret_ key returns HTTP 401 from a browser, but it still leaks if you bundle it, so keep both off the client.
// lib/supabase.ts (imported by client components)
import { createClient } from '@supabase/supabase-js'
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
// ships to every visitor and bypasses RLS
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!
)// lib/supabase-browser.ts (safe for client components)
import { createClient } from '@supabase/supabase-js'
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
)
// lib/supabase-admin.ts (server only, run: npm i server-only)
import 'server-only'
import { createClient } from '@supabase/supabase-js'
export const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!
)Anything with the NEXT_PUBLIC_ prefix is bundled for every visitor, so never put a secret key behind it. More in NEXT_PUBLIC_ environment variables.
How to lock down your Supabase tables, step by step
Do this for every table in an exposed schema. Tables created in the Dashboard have RLS on by default. Tables made in the SQL Editor or with another tool don't, so enable it yourself.
Enable RLS
Run this in the SQL Editor or a migration. Once RLS is on, the public key sees nothing until you add policies.
alter table public.reports enable row level security;
Revoke the default grants
Take every privilege back from both client roles, then grant back only what the app uses. Keep grants and RLS in the same migration.
revoke all on table public.reports from anon, authenticated; grant select, insert, update, delete on table public.reports to authenticated;
New tables get the default grants again, so repeat this for each one. To change the defaults themselves, check the current Supabase docs for the Data API settings.
Write one policy per operation
Tie each policy to the signed-in user with
auth.uid(). Add update and delete policies the same way. An update also needs a matching select policy.create policy "Users read own reports" on public.reports for select to authenticated using ((select auth.uid()) = user_id);
Review views and functions
RLS doesn't apply to functions. Grant
executeonly to the roles that need it and review everysecurity definerfunction, which runs with its owner's permissions instead of the caller's. Treat each view like a table.Run the Supabase Security Advisor
Open Security Advisor in Supabase Studio. Look for RLS disabled in public, security definer views, and policies that reference
user_metadata(see Supabase user_metadata). Some findings are intentional, so compare each one to what you meant.
Then confirm the result with the tests in the next section.
Test it yourself with curl
Only run these against your own project. A bad result means the write went through, so use a staging copy or delete the test row afterwards. Use the public key and nothing from a logged-in session.
# Read as an anonymous visitor
curl "https://YOUR_PROJECT.supabase.co/rest/v1/reports?select=*&limit=3" \
-H "apikey: sb_publishable_xxx"
# Try a write as an anonymous visitor
curl -X POST "https://YOUR_PROJECT.supabase.co/rest/v1/reports" \
-H "apikey: sb_publishable_xxx" \
-H "Content-Type: application/json" \
-d '{"title":"leak test"}'Use real column names in the -d body. A 400 error about a missing column proves nothing, because the request never reached your policies.
- Bad: the read returns rows you didn't mean to publish, or the write returns HTTP 201.
- Good: the read returns
[]or a42501error (permission denied), and the write fails with42501.
A table that is a public feed on purpose will return rows. Then repeat the tests as a second signed-in user, with their access token in an Authorization: Bearer header next to the same apikey header. Confirm they can't touch the first user's rows. This is where to authenticated using (true) leaks.
Next, check which key your frontend holds. Adjust the folders to your framework's build output.
npm run build grep -rlE "sb_secret_|service_role" .next/static dist 2>/dev/null
No output is what you want. Legacy keys are JWTs that start with eyJ, so a service_role key won't match that text. Copy the apikey header from a request in the Network tab, decode the middle part of the JWT and check that its role says anon. If it says service_role, go straight to the next section.
What to do if your service_role key leaked
Assume everything is exposed. A secret key bypasses every RLS policy, so whoever holds it can read and change all your data. That includes a copy in a public repo, a shipped bundle, a chat message or a log.
Supabase advises fixing the root cause before you rotate, or the new key leaks the same way. Then work in this order:
- Create a new secret key in Settings > API Keys in the Dashboard.
- Replace the old key everywhere it's used.
- Confirm every component runs on the new key.
- Retire the old key.
Deleting a secret key can't be undone. The legacy service_role and anon keys are deactivated in the same Dashboard section, and you can reverse that if you missed a client. Check your web and mobile builds first.
Afterwards, look through your tables and auth users for changes you didn't make. For the general routine, see leaked API key.
Fix prompt you can paste
Give this to Cursor or Claude Code. It audits key placement first, then RLS.
Audit how this repo uses Supabase keys and Row Level Security. First print findings as file:line, the issue, and the proposed fix. Then make the edits. 1. Search for createClient( calls and every use of SUPABASE_SERVICE_ROLE_KEY, SUPABASE_SECRET_KEY, sb_secret_, service_role, and any NEXT_PUBLIC_ or VITE_ env var containing SERVICE_ROLE or SECRET. 2. Move any elevated key used in client code (or files it imports) to a server-only module (import 'server-only') with a non-public env var. Browser code must use NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY. 3. Find tables in supabase/migrations or .sql files created without 'enable row level security'. Write a migration that enables RLS, revokes all from anon and authenticated, grants back only what the app uses, and adds one policy per operation using (select auth.uid()) = user_id. 4. Flag policies with using (true) or user_metadata, views over RLS tables, security definer functions, and committed .env files containing keys. 5. Don't change tables meant to be public. List them so I can confirm. 6. Never print or write a real key. Use placeholders like sb_publishable_xxx. After editing, list each changed file with line ranges and explain each change in one or two sentences.
After you apply it, you can run Leakcade on your app as a second check.
Further reading
FAQ
Is it safe to commit my Supabase anon key to GitHub?
Yes. The docs list source code as a fine place for the publishable key, and the legacy anon key works the same way. It only stays safe if RLS is on for your tables. Never commit the secret or service_role key.
What is the difference between the Supabase publishable key and the anon key?
The publishable key (sb_publishable_...) is the new version of the legacy anon JWT. Both are low privilege. The legacy keys stay valid until you disable them in the Dashboard.
What happens if my Supabase service_role key is leaked?
Whoever has it bypasses every RLS policy and gets full access to your project's data. Fix the cause of the leak, create a new secret key, replace the old one everywhere, then retire the old key in Settings > API Keys.
Do I need RLS if I only query Supabase from server code?
Yes, if the Data API (the REST and GraphQL endpoints Supabase generates for your tables) is still on. Anyone can call it with your public key, whatever your app does. Code using the secret key skips RLS, so it needs its own authorization checks.
Want a second pair of eyes?
We review your whole repo and write a fix prompt for every finding. $19, report within an hour.
Check my repoLeakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms