LEAKCADE

Is Supabase anon key safe to expose? The RLS answer

Updated 8 min read

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.

Same public key, two outcomesTwo lanes show the same request. With RLS off and default grants in place, the attacker copies the key from your JavaScript, calls the REST API and reads or writes every row. With RLS on, the same call returns an empty result or a permission error.RLS OFF1Key is copiedfrom your JS2Attacker calls/rest/v1/directly3RLS off +grants: table isopen4All rowsreadable,writableRLS ON1Key is copiedfrom your JS2Attacker calls/rest/v1/directly3RLS checks theanon role4Empty result orerror 42501RLS OFF1Key is copied from your JS2Attacker calls /rest/v1/ directly3RLS off + grants: table is open4All rows readable, writableRLS ON1Key is copied from your JS2Attacker calls /rest/v1/ directly3RLS checks the anon role4Empty result or error 42501
The key is public either way. RLS decides what it can reach.

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)
Formatsb_publishable_... (legacy: anon JWT)sb_secret_... (legacy: service_role JWT)
Row Level SecurityAppliesBypassed
Safe in browser codeYesNever
Where it belongsBrowser, mobile appServer, 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:

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.

BEFOREWrong: elevated key in client code
// 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!
)
AFTERRight: public key in browser, secret key on server
// 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!
)
The admin client lives in a server-only file and reads variables without the NEXT_PUBLIC_ prefix.

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.

  1. 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;
  2. 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.

  3. 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);
  4. Review views and functions

    RLS doesn't apply to functions. Grant execute only to the roles that need it and review every security definer function, which runs with its owner's permissions instead of the caller's. Treat each view like a table.

  5. 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.

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:

  1. Create a new secret key in Settings > API Keys in the Dashboard.
  2. Replace the old key everywhere it's used.
  3. Confirm every component runs on the new key.
  4. 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.

FIX PROMPT
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

  1. Supabase docs: API keys
  2. Supabase docs: Row Level Security
  3. Supabase docs: Securing your API

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 repo

Leakcade 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

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.