A Supabase RLS misconfiguration is a database table that is reachable over the internet by anyone holding the public anon key, because Row Level Security is either switched off or governed by a policy that returns true for every caller. Supabase publishes a REST endpoint for every table in your public schema automatically, and the anon key that authorises those calls sits in your browser bundle where anybody can read it. Row Level Security is the only thing standing between a stranger and every row you own.
What does Row Level Security actually do?
Row Level Security attaches a filter to a Postgres table so that every query, whoever issues it, only sees rows the filter allows. It runs inside the database, below your application code. Once RLS is enabled, Postgres denies every row by default and you grant access back one policy at a time.
Take an invented app, Acme Notes. Each user writes private notes, and the table has an owner_id column. A correct policy says: read a row only if the caller’s id matches its owner.
alter table notes enable row level security; create policy "read own notes" on notes for select to authenticated using ( auth.uid() = owner_id );
The important part is auth.uid(). Supabase verifies the caller’s JWT and hands Postgres the verified user id, and the policy compares it against the row. A caller with no session has no auth.uid(), so the comparison fails and the result is empty. Access control is now a property of the data, not of the screen that renders it.
Why is the anon key not a secret?
The anon key is designed to be public. It is a JWT with the role anon baked in, shipped to the browser so the client can talk to your project without a server in the middle. Anyone can open devtools, read it out of the JavaScript bundle, and use it from curl. That is fine, as long as every table it reaches has policies deciding what an anonymous caller may see.
The service role key is the opposite. It carries the service_role claim, and that role bypasses RLS entirely by design. If it ever lands in a client bundle, a browser exposed environment variable, a mobile binary, or a public repository, every policy you wrote stops mattering at once.
The anon key is not a vulnerability. A table that answers the anon key with all of its rows is.
What are the four shapes of a Supabase RLS misconfiguration?
Nearly every real case is one of four shapes, all ending in the same place: a caller reads or writes rows that are not theirs.
- RLS never enabled. The table was created by a migration or a raw SQL statement and nobody ran
enable row level security. Postgres applies no filter, the auto API serves it, and a plain GET returns the table. - A policy that says true. Someone hit a permission error in development, reached for the fastest unblock, and wrote
using (true). RLS is on, a policy exists, and it grants every row to everyone. - A policy that checks nothing meaningful. It references a column the caller controls rather than an identity the database verified. A filter like
using (is_public = true)is only as good as who may setis_public, and a policy keyed off a value from the request body is a filter the attacker fills in. - The service role key in the client. Policies are correct, thorough, and irrelevant, because the key in the browser bypasses all of them.
Shape two is the one people ship on purpose:
-- looks like a policy, is not a policy create policy "enable read access for all users" on notes for select using ( true ); -- and the write side of the same mistake create policy "enable insert for all users" on notes for insert with check ( true );
This is common rather than rare. A published scan of gallery projects built on the Lovable app builder reported roughly 170 of 1,645 applications exposing data through missing or inadequate RLS, and separate scanning by Escape.tech on production apps assembled with AI builders found a majority carried security issues. We have not tested those applications, and cite both as published third party work. The pattern is what matters: fast assembly puts a database on the internet in an afternoon, and access control is the step that gets deferred.
Why is a green RLS badge not the same as secure?
Because the dashboard reports whether RLS is enabled, not whether your policies mean anything. A table with RLS on and a single using (true) select policy shows the same reassuring state as a table locked down correctly. The badge answers “is the mechanism on,” and the question you care about is “who does this mechanism let in.”
The gap widens as an app grows. Policies are written per table and per operation, and a table added late by a migration inherits nothing. Write policies get forgotten more often than read policies, which is how a stranger ends up able to insert rows into a table whose reads were locked down months ago.
How do you verify RLS from outside instead of trusting the dashboard?
Ask the API the way a stranger would: public anon key, no session, no client library in the way. The auto generated REST endpoint is the ground truth.
curl "https://PROJECT.supabase.co/rest/v1/notes?select=*" \ -H "apikey: PUBLIC_ANON_KEY"
An empty array [] means the policies held. Rows coming back mean a stranger reads that table. Then repeat in three more positions, each catching a different failure:
- Signed in as a real user, asking for another user’s rows. Add a filter such as
?owner_id=eq.SOMEONE_ELSEand confirm the result is empty. - Write, not just read. Send a POST and a PATCH as an anonymous caller. Read and write policies are separate objects, so testing reads alone leaves half the table untested.
- Every table, not the ones you remember. Enumerate what the API exposes and test each one, since the risky table is usually the one added last.
How do you prevent it?
- Deny by default. Enable RLS in the same migration that creates the table, not later. RLS on with no policies returns nothing, which is the correct starting state.
- Write policies against verified identity. Use
auth.uid(), or a tenant id read from the verified JWT, against a column the user cannot set. Never key a policy off a value the request supplies. - Treat
using (true)as a finding. Grep your migrations for it. If a table really is public, restrict the columns and say so deliberately, rather than letting a temporary unblock become the rule. - Keep the service role key server side only. No browser bundle, no client environment variable, no mobile binary. Rotate it if it was ever committed.
- Test each policy from two hostile seats. An unauthenticated caller, and a signed in user of a different tenant. Make both assertions in CI so a future migration cannot quietly reopen the table.
- Assume every exposed table is internet facing. Anything the auto API serves has a public URL whether or not your app calls it.
Why do scanners miss this?
Because nothing here is malformed. The request is well formed, the key is valid, the endpoint is documented, and the response is a clean 200. This is broken access control, the same class as broken object level authorization, its cousin broken function level authorization, and the field level variant in broken object property level authorization. A scanner can tell you a URL responded. It cannot tell you the rows in that response belonged to someone else, because who is allowed to see what is a fact about this application and nothing else. The same reasoning gap shows up in client side paywall bypass and across the patterns in our guide to securing quickly built apps.
Answering it takes a tester that learns the app’s own rules about ownership and tenancy, forms an idea about where the database does not enforce them, and proves it by fetching a row it should never have been given. That is exactly the assumption an autonomous researcher built to test assumptions, rather than match payloads, is meant to probe. More of our writing on it sits under access control and on our about page.
Frequently asked questions
What is a Supabase RLS misconfiguration?
It is a table exposed through Supabase’s automatic REST API with Row Level Security either switched off or governed by a policy that returns true for every caller. Anyone holding the public anon key can then read, and sometimes write, rows that are not theirs.
Is it safe to put the Supabase anon key in the browser?
Yes, the anon key is designed to be public and ships in your client bundle by design. It is only safe when every table it can reach has policies that decide what an anonymous caller may see. The service role key is different because it bypasses Row Level Security entirely and must never leave your server.
Why is a policy of USING true dangerous?
Because it grants every row to every caller while the dashboard still reports that Row Level Security is enabled. The badge answers whether the mechanism is on, not whether your policy means anything, so a table with that policy looks identical to one that is locked down.
How do you test whether Row Level Security is working?
Query the REST endpoint from outside with the public anon key and no session, and confirm the response is an empty array. Then repeat as a signed in user asking for another user’s rows, test writes as well as reads, and run the check against every table the API exposes.
Put an autonomous researcher on your own systems
UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.
