Short answer: the Supabase anon key, now called the publishable key, is meant to be exposed in your frontend. It is not a leak. The only thing standing between that public key and your data is Row Level Security. If RLS is turned off, or it isn't configured correctly, anyone who pulls that key out of your JavaScript can reach your database tables and read information they were never supposed to see.
Before any of this, do one thing: ask your AI to list every table in your database and explain what each one is for. You need to know which tables should be publicly readable and which should never be, because only you know what your app actually does. Once you understand that, you can direct the AI properly instead of handing it the whole problem and hoping.
Below is the checklist and an audit prompt you can paste into your AI. The explanation of why each item matters follows underneath.
Every claim here is sourced from Supabase's own documentation, linked at the bottom.
The checklist
Understand your own database first
- You can list every table and say what it is for
- You know which tables should be readable by anyone, and which should never be
- You decided that, not the AI
Tables
- RLS enabled on every table in an exposed schema, including every table created in raw SQL or by a migration
- Every table has policies, not just RLS switched on
- No policy uses
USING (true)or1=1 - No
FOR ALLpolicies, four separate ones instead - Every UPDATE policy has both
USINGandWITH CHECK - Every UPDATE policy has a matching SELECT policy
- Policies handle the null case explicitly:
auth.uid() is not null and ... - No policy reads
user_metadata, which the user can write to - Role-based access reads from a table you control, or a server-set custom claim
- The tenant or org id comes from the session or a server-set claim, never from request input
Performance
- Function calls wrapped:
(select auth.uid()) - Indexes on columns used in policies
-
to authenticatedspecified on every policy - Joins written user-first, not row-first
Beyond tables
- Views created with
security_invoker = on - No materialized views or foreign tables carrying data that RLS should protect
- SECURITY DEFINER functions have
search_path = ''and fully qualified table names - Storage buckets have policies on
storage.objects - Public buckets have no broad listing policy
- Realtime: "Allow public access" disabled if you rely on private channels
- Realtime: you have thought about DELETE payloads, which RLS does not filter
- Edge Functions authorize the caller in code if
verify_jwtis off
Keys
- No secret or service_role key anywhere in frontend code, the repo, or the built bundle
- You know which of your keys is publishable and which is secret
- Any key that has ever leaked was rotated, and for signing keys, explicitly revoked
- Grants reviewed ahead of the 30 October 2026 Data API change
Verification
- Security Advisor run, ERROR-level findings cleared
- App opened logged out, tried to read data
- App opened as a second user, tried to read the first user's data
The audit prompt
Paste this into your AI along with your schema and policies. It is written to make the model report gaps rather than reassure you.
Audit the Supabase security configuration in this project. Your job is to find
what is missing or wrong, not to tell me it looks fine.
For every item below answer PRESENT (and show the code), MISSING, or INCORRECT
(and explain what an attacker could do). If you cannot find the relevant code,
say MISSING. Do not assume Supabase handles something unless you can point at
the configuration proving it. At the end, rank findings by exploitability and
give me the fix for the top five.
START HERE
1. List every table in the database and explain in one line what each table
appears to be for, based on its columns and how the app uses it. Then tell
me which ones look like they hold data that should be private to one user
or one organisation. I will confirm before you continue.
TABLES AND POLICIES
2. List every table in an exposed schema and whether RLS is enabled on each.
3. List every table that has RLS enabled but no policies, and every table that
has policies but does not have RLS enabled.
4. Find any policy using USING (true), 1=1, or no meaningful condition.
5. Find any FOR ALL policies.
6. For every UPDATE policy: does it have both USING and WITH CHECK? If it only
has USING, explain that a user can reassign ownership of their own row.
7. For every UPDATE policy: is there a matching SELECT policy?
8. Find any policy that would behave unexpectedly when auth.uid() is null.
9. Find any policy that reads user_metadata. That data is user-writable and
this is an account escalation path.
10. Where does role or tenant information come from? If it comes from request
input or user_metadata rather than the session or a server-set claim,
flag it.
BEYOND TABLES
11. List every view. Which are created with security_invoker = on? Any view
without it may bypass RLS on its underlying tables.
12. Any materialized views or foreign tables exposed to the API?
13. Any SECURITY DEFINER functions? Do they set search_path and fully qualify
table names? Can anon or authenticated execute them?
14. Storage: are there policies on storage.objects? Any public bucket with a
broad listing policy?
15. Realtime: is private mode relied on? Has public access been disabled? Are
DELETE payloads a concern given RLS does not filter them?
16. Edge Functions: is verify_jwt disabled anywhere, and if so does the handler
authorize the caller itself?
KEYS
17. Search the entire codebase, including the built bundle and committed env
files, for a service_role or sb_secret_ key. Report any hit as critical.
18. Is any key sent in an Authorization Bearer header that should be sent in
the apikey header?
PERFORMANCE
19. Any policy calling auth.uid() without wrapping it as (select auth.uid())?
20. Any policy without a TO clause?
21. Are the columns used in policies indexed?
Now audit and report.Why each of these matters
The rest of this is the reasoning behind the checklist. Read it if a line above did not make sense, or if you want to know how these fail in practice.
The two keys
Supabase gives you two kinds of key and the difference is the whole ballgame.
Publishable, formerly anon. Safe in the browser. Every request it makes is subject to RLS. Supabase's framing is useful: the publishable key identifies what is accessing the project, while Auth identifies who.
Secret, formerly service_role. Runs as a Postgres role carrying the BYPASSRLS attribute. It skips every policy you have ever written. Supabase's instruction is blunt: never use it in a browser, even on localhost.
Supabase now actively defends this. A secret key used from a browser gets HTTP 401 back. That guardrail only exists on the new sb_secret_ format, and it exists because people kept doing it.
The trap
The sequence goes like this, and it is almost universal.
You enable RLS. Your query starts returning an empty array. Nothing errors, the request succeeds, the data is simply gone. You are on a deadline. Somewhere you have a service key that makes it work again, so you swap it in.
It works. Of course it works. It works because the service key ignores the policy you just wrote.
Read what actually happened. The empty array was the system functioning correctly. RLS with no policies denies everything by default, which is exactly what it should do. You did not fix a bug. You disabled the control and shipped the result.
Supabase's own linter has a name for the other version of this shortcut. Rule 0024 flags policies written as USING (true), and their description of where those come from is unusually honest: "often from copy-paste errors or forgotten development placeholders."
RLS is on by default, except when it isn't
Create a table in the dashboard Table Editor and RLS is enabled for you.
Create the same table in the SQL editor, or in a migration, or by asking an AI to write the schema, and it is not. Supabase's docs say it plainly: if you create one in raw SQL, remember to enable RLS yourself.
Which path do you think AI-generated projects take?
alter table public.your_table enable row level security;Four ways policies fail silently
These are ordered by how often they slip through.
1. The null comparison
auth.uid() returns null for an anonymous request. In SQL, null = user_id evaluates to null, which is not true, so the row is filtered. That sounds correct, and in the SELECT case it usually is. The problem is that nothing anywhere errors or warns, so a policy that is subtly wrong looks identical to one that is right.
Be explicit instead of relying on null semantics:
using ( auth.uid() is not null and (select auth.uid()) = user_id )2. UPDATE without WITH CHECK
USING decides which existing rows you may touch. WITH CHECK decides what the row is allowed to look like afterwards. Supabase's rules per command:
- SELECT:
USING, noWITH CHECK - INSERT:
WITH CHECK, noUSING - UPDATE: both
- DELETE:
USING, noWITH CHECK
Now the trap. From the Postgres behaviour Supabase documents: if no WITH CHECK expression is defined, the USING expression is used both to determine which rows are visible and which new rows are allowed.
So write an UPDATE policy with only USING (auth.uid() = user_id) and a user can update their own row and reassign user_id to somebody else. They passed the visibility check on the old row. Nothing validated the new one.
create policy "Users can update their own profile."
on profiles for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );Second-order gotcha: UPDATE also requires a SELECT policy to exist, or it will not work as expected.
And do not write FOR ALL. Supabase's guidance is four separate policies, one per command, because FOR ALL hides exactly the asymmetry above.
3. Policies built on user_metadata
This one is rated ERROR by Supabase's linter, rule 0015, and the exploit is a single line of client-side code.
user_metadata is designed to be manipulated by the user themselves. It is writable from the browser. So a policy like this:
using ( ((select auth.jwt()) -> 'user_metadata' ->> 'is_admin')::bool )is defeated by any user running:
supabase.auth.updateUser({ data: { is_admin: true } })Supabase admits there is no one-size-fits-all replacement. The sanctioned route is a Custom Access Token Auth Hook that injects claims server-side before the token is issued, reading from a table you control:
select role into user_role from public.user_roles where user_id = (event->>'user_id')::uuid;
claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));Then read it back in policies with (auth.jwt() ->> 'user_role'). One wrinkle: the hook modifies the access token but not the auth response, so the browser has to decode the JWT to see the claim.
4. Views that bypass everything
This is the one nobody checks, and it is rated ERROR too.
Postgres views default to SECURITY DEFINER semantics. Supabase calls that "an unintuitive default, chosen for backwards compatibility." The consequence, in their words: a public security definer view referencing auth.users exposes all user records to all API users.
You can enable RLS on every table you own and still hand the whole thing over through a view.
create view public.my_view with (security_invoker = on) as select ...;The same class covers materialized views, which bypass RLS protections, and foreign tables, which cannot enforce it at all. Rules 0016 and 0017.
Make your policies fast
A policy runs against every row the database examines. Write it carelessly and a query that should take milliseconds takes seconds, which is why performance belongs in a security guide at all: in my experience people respond to a slow app by loosening the thing that slowed it down. That last part is my observation, not Supabase's. The numbers below are theirs, and they are not marginal.
Wrap function calls in a subselect. Writing (select auth.uid()) = user_id instead of auth.uid() = user_id causes the planner to run an initPlan and cache the result rather than calling the function once per row. Their tests: 11,000ms down to 7ms, and in another case 178,000ms down to 12ms.
Index the column the policy filters on. Reported as roughly a 99.94% improvement on a large table.
Always specify the role. Adding to authenticated took a case from 170ms to under 0.1ms for anonymous requests, because without it the policy is evaluated even for callers who could never pass it.
Flip your join direction. Slow: auth.uid() in (select user_id from team_user where team_user.team_id = table.team_id). Fast: team_id in (select team_id from team_user where user_id = auth.uid()).
Filter client-side as well. Adding .eq('user_id', userId) to a query that RLS already constrains took 171ms to 9ms.
Rule 0003 flags un-wrapped function calls for you.
Storage, Realtime, and the parts that are not tables
Storage
Storage objects need their own policies, written against storage.objects. Supabase does not allow uploads to buckets without them.
The public bucket risk is sharper than people assume. It is not only that files are readable by URL, which is the point of the flag. It is enumeration. Rule 0025 catches a public bucket that also carries a broad SELECT policy, which upgrades "readable if you know the URL" into "list everything in here." Their note: clients can list objects through Storage APIs, which is often broader access than the project intended.
Fix is to drop the listing policy. Object URLs keep working.
Realtime
Two genuinely dangerous behaviours here.
DELETE events are not filtered by RLS. Supabase's explanation: there is no way for Postgres to verify that a user has access to a deleted record. Combine that with replica identity full, which you need in order to filter deletes at all, and deleted-row payloads can reach subscribers who could never have selected that row.
Private channels only work if you close the public door. Opening a channel with private: true is not sufficient. You must also disable "Allow public access" in Realtime settings, otherwise a client simply omits the flag and skips authorization entirely.
Also worth knowing: policies are cached for the duration of the connection. Revoking someone's access does not disconnect a live subscriber.
Edge Functions
The verify_jwt setting only understands legacy JWT-format keys. The new sb_secret_ and sb_publishable_ keys are not JWTs, so the platform check rejects them. The documented workaround is to set verify_jwt = false and authorize in your own code.
That is a real footgun. People disable the check, ship, and never write the replacement.
Grants and RLS are two different layers
Worth stating because it is about to matter operationally. Grants decide which Postgres roles can reach a table over the Data API. RLS decides which rows those roles can see. Supabase's instruction is to use both controls on every exposed object.
A dated change is coming. From 30 October 2026, new tables stop being automatically exposed to the Data API on existing projects. New projects already default this way as of 30 May 2026.
The good news is the failure is loud rather than silent. PostgREST returns permission denied for table your_table with a hint containing the exact grant you need. Direct Postgres connections, ORMs and psql are unaffected.
grant select on table public.your_table to anon;
grant select, insert, update, delete on table public.your_table to authenticated;How to actually test a policy
Four options, in ascending order of rigour.
User impersonation in Studio. Use the Table Editor and SQL Editor as a specific user and see what they see.
The RLS Tester, a feature preview, shows which policies were evaluated and which failed. Important limitation: it only supports SELECT queries, so it cannot catch the UPDATE WITH CHECK bug above.
Manual session simulation in the SQL editor:
set session role authenticated;
set request.jwt.claims to '{"role":"authenticated", "sub":"<user-uuid>"}';
explain analyze select * from your_table;pgTAP with supabase test db for policies you want covered permanently. It ships assertions like policies_are(), policy_roles_are() and policy_cmd_is() for asserting policy shape, plus results_eq() for asserting behaviour.
If you do nothing else here, do the cheapest version: open your app logged out, and then logged in as a second user, and try to read the first user's data.
Run the Security Advisor
It is in the dashboard under Database, and it catches most of this article automatically. The rules worth knowing by name:
| Rule | Level | Catches |
|---|---|---|
| 0013_rls_disabled_in_public | ERROR | RLS off on a public-schema table |
| 0002_auth_users_exposed | ERROR | User data exposed through a view |
| 0010_security_definer_view | ERROR | View bypasses RLS |
| 0015_rls_references_user_metadata | ERROR | Policy trusts user-editable data |
| 0023_sensitive_columns_exposed | ERROR | Password or card or medical-shaped columns with no RLS |
| 0019_insecure_queue_exposed_in_api | ERROR | Queue exposed unprotected |
| 0024_permissive_rls_policy | WARN | USING (true) and friends |
| 0003_auth_rls_initplan | WARN | Slow per-row policy |
| 0006_multiple_permissive_policies | WARN | Policies compounding access unintentionally |
| 0025_public_bucket_allows_listing | WARN | Public bucket is enumerable |
| 0028 / 0029 definer function executable | WARN | SECURITY DEFINER function callable from the API |
| 0026 / 0027 graphql table exposed | WARN | Table reachable via /graphql/v1 |
| 0007_policy_exists_rls_disabled | INFO | Policies written, RLS never switched on |
| 0008_rls_enabled_no_policy | INFO | RLS on, no policies, table reads empty |
Rule 0007 deserves a highlight. You can write a full set of correct policies and never enable RLS, and everything will look done.
If a key leaked
Rotate. Deleting the commit is not remediation.
New-format secret key. Create a new one, swap it in, delete the old one. Multiple secret keys coexist, so this is zero downtime. Deletion is irreversible.
Legacy JWT secret. Violent. Once regenerated, all current API secrets are immediately invalidated and all connections using them are severed. Every session dies. Supabase's own advice now is to migrate to the new key format rather than rotate legacy secrets.
Asymmetric signing keys. Rotating is not enough. If you do not explicitly revoke the old key, it stays valid. Wait at least 20 minutes between steps, because JWKS is cached around 10 minutes at the edge.
Four things that break when you move to the new key format, all documented:
- New keys go in the
apikeyheader, notAuthorization: Bearer. Sending them the old way returnsInvalid JWT. - Database webhooks and
pg_nettraditionally send the service key as a bearer token, which now gets rejected. - Edge Functions need
verify_jwt = falseplus your own check. - Public Realtime connections are limited to 24 hours without user-level auth.
Sources
Supabase documentation
- Row Level Security | https://supabase.com/docs/guides/database/postgres/row-level-security
- RLS simplified (USING vs WITH CHECK) | https://supabase.com/docs/guides/troubleshooting/rls-simplified-BJTcS8
- RLS policy rules | https://supabase.com/docs/guides/getting-started/ai-prompts/database-rls-policies
- RLS performance and best practices | https://supabase.com/docs/guides/troubleshooting/rls-performance-and-best-practices-Z5Jjwv
- Understanding API keys | https://supabase.com/docs/guides/api/api-keys
- Migrating to new API keys | https://supabase.com/docs/guides/getting-started/migrating-to-new-api-keys
- Securing your API | https://supabase.com/docs/guides/api/securing-your-api
- Custom claims and RBAC | https://supabase.com/docs/guides/database/postgres/custom-claims-and-role-based-access-control-rbac
- Storage access control | https://supabase.com/docs/guides/storage/security/access-control
- Realtime postgres changes | https://supabase.com/docs/guides/realtime/postgres-changes
- Realtime authorization | https://supabase.com/docs/guides/realtime/authorization
- Edge Functions auth | https://supabase.com/docs/guides/functions/auth
- Database advisors | https://supabase.com/docs/guides/database/database-advisors
- Database linter rules | https://supabase.com/docs/guides/database/database-linter
- Database functions | https://supabase.com/docs/guides/database/functions
- Custom schemas | https://supabase.com/docs/guides/api/using-custom-schemas
- Testing your database | https://supabase.com/docs/guides/database/testing
- pgTAP | https://supabase.com/docs/guides/database/extensions/pgtap
- Rotating anon, service and JWT secrets | https://supabase.com/docs/guides/troubleshooting/rotating-anon-service-and-jwt-secrets-1Jq6yd
- JWTs and signing keys | https://supabase.com/docs/guides/auth/jwts
- Going into prod checklist | https://supabase.com/docs/guides/deployment/going-into-prod
- Data API breaking change, Oct 2026 | https://supabase.com/changelog/45329-breaking-change-tables-not-exposed-to-data-and-graphql-api-automatically
- User impersonation | https://supabase.com/features/user-impersonation
- RLS Tester feature preview | https://supabase.com/changelog/45233-feature-preview-rls-tester
- Security retro 2025 | https://supabase.com/blog/supabase-security-2025-retro
Incidents
- CVE-2025-48757, Lovable | https://nvd.nist.gov/vuln/detail/CVE-2025-48757 and https://mattpalmer.io/posts/2025/05/statement-on-CVE-2025-48757/ (disputed by the vendor)
- Moltbook exposure, Wiz Research, Feb 2026 | https://www.wiz.io/blog/exposed-moltbook-database-reveals-millions-of-api-keys
- Symbiotic Security, 1,072 vibe-coded apps scanned | https://www.symbioticsec.ai/blog/we-scanned-1-072-vibe-coded-apps-98-had-security-flaws
- Escape.tech vibe-coded app research | https://escape.tech/blog/methodology-how-we-discovered-vulnerabilities-apps-built-with-vibe-coding/
A note on scope: Supabase does not publish a dedicated multi-tenant RLS guide. The org-scoped join pattern shown here comes from their performance documentation. Database webhook payloads are generated at trigger level and are therefore not RLS-filtered, but Supabase does not document that directly, so treat it as reasoning rather than a cited claim.