Supabase exposes your Postgres tables through an API that anyone with your public anon key can call, so Row Level Security (RLS) is what keeps one customer's data away from everyone else. Most leaks come from a short list of mistakes: RLS left off, policies that are too broad, missing WITH CHECK rules, and server code that bypasses RLS entirely.
On this page
- Why RLS matters more in Supabase
- Mistake 1: RLS not enabled on a table
- Mistake 2: Policies that allow everyone
- Mistake 3: Missing WITH CHECK on insert and update
- Mistake 4: Trusting data the user can edit
- Mistake 5: Using the service role key where a user client belongs
- Mistake 6: Views and functions that bypass RLS
- How to test your policies
- Get your Supabase project audited
Why RLS matters more in Supabase
In a traditional backend, the database sits behind your own API, and only your server talks to it. In Supabase, the browser talks to the database through the auto-generated API. That is what makes Supabase fast to build with, and it is why the Supabase docs treat RLS as essential for any table in an exposed schema.
RLS is a PostgreSQL feature: each policy is a SQL expression that Postgres adds to every query on the table, so a user can only see or change the rows the policy allows. See the PostgreSQL documentation on row security.
On LeadsMind I audited 157 API routes and fixed RLS across the platform. These are the patterns that came up, and the ones I check first in every audit.
Mistake 1: RLS not enabled on a table
A table created with SQL in an exposed schema does not get RLS automatically unless you enable it. Without RLS, the API can read and write that table with the anon key. Enable it on every table in the public schema, then add policies:
alter table public.leads enable row level security;Enabling RLS with no policies denies all access through the API. That is safe, but your app will stop working until the right policies exist, so test each one.
Mistake 2: Policies that allow everyone
The quickest way to make an error go away is a policy like using (true). It also gives every signed-in user, and sometimes everyone, access to every row. Scope each policy to the owner or the tenant:
create policy "tenant_isolation" on public.leads
for all to authenticated
using (org_id = (select auth.jwt() ->> 'org_id')::uuid)
with check (org_id = (select auth.jwt() ->> 'org_id')::uuid);Note the to authenticated role, so anonymous requests are excluded, and wrapping the function in select, which the Supabase docs recommend so Postgres can evaluate it once per query instead of once per row.
Mistake 3: Missing WITH CHECK on insert and update
USING controls which existing rows a user can see or target. WITH CHECK controls what the row is allowed to look like after an insert or update. Without it, a user who can update their own row may be able to change its org_id and move data into another tenant, or insert rows on behalf of someone else.
Every insert and update policy needs a WITH CHECK that ties the new row to the current user or tenant.
Mistake 4: Trusting data the user can edit
Policies that read roles or tenant IDs from user-editable metadata can be bypassed, because users can update their own user metadata. Supabase's guidance is to use app metadata (set only by the server), a custom access token claim, or a lookup in a table the user cannot write to.
A safe pattern is a memberships table that maps users to organisations, protected by its own RLS, which other policies check with exists (...).
Mistake 5: Using the service role key where a user client belongs
The service role key bypasses RLS completely. It belongs only in trusted server code for admin tasks, never in the browser. See the Supabase API keys guide.
The subtler version of this mistake is in API routes: a Next.js route handler that uses a service role client to fetch data "for the current user" with a filter in JavaScript. If the filter is wrong or missing, RLS is not there to catch it. In the LeadsMind audit, this is exactly the kind of route I looked for. Use a client that carries the user's session so RLS applies, and reserve the service role for jobs that genuinely need it.
Mistake 6: Views and functions that bypass RLS
- Views run with the permissions of their owner by default, which can skip the RLS of the underlying tables. On Postgres 15 and later, create them with
security_invoker = trueso the caller's policies apply. - Security definer functions run with the creator's privileges. Keep them out of exposed schemas, or check permissions inside the function, and set a fixed
search_path. - Storage buckets have their own policies on
storage.objects. Private files need policies too.
How to test your policies
- List every table, view, function and bucket in exposed schemas, and who should access each.
- Create two test users in two different organisations.
- For each table, try select, insert, update and delete as user A against user B's rows, and as an anonymous client.
- Check every server route that uses the service role key, and justify each one.
- Add indexes on the columns your policies filter by, such as
org_idanduser_id, so security does not slow queries down.
The Supabase dashboard's security advisor also flags tables without RLS, which is a useful first pass but not a full audit.
Get your Supabase project audited
If you built quickly and are not sure what your anon key can reach, an audit gives you a ranked list of gaps and tested fixes. See my Supabase developer and RLS audit service, or read how I approached it in the LeadsMind case study.