Skip to content
shipanysaas

Supabase RLS explained: row level security that keeps tenants apart

What Supabase RLS is, how to enable or disable it, why the secret key bypasses it, and how to write and test row level security for a multi-tenant app.

RLS in Supabase is Postgres row level security: rules on each table that decide which rows a request may read or change, checked inside the database on every query. Because browsers and phone apps query Supabase directly with a public key, Supabase RLS is what stops one signed-in user from reading another customer's data.

This guide answers the questions people ask about it, then builds a multi-tenant setup step by step: an account_id on every tenant row, grants per role, one policy per command, a membership check that stays fast, and tests that sign in as the wrong tenant. The worked example is the shipanysaas schema.

What does RLS do in Supabase?

It adds a filter to every query a client sends, based on who is asking. Supabase's row level security guide says to think of a policy as adding a WHERE clause to every query: with using ((select auth.uid()) = user_id), a bare select * from todos returns only the caller's rows.

Three facts shape everything else:

  • No policy means no rows. Once RLS is on, a command that no policy allows sees nothing and changes nothing.
  • The key decides the role. Requests with the publishable key run as anon when nobody is signed in and as authenticated when someone is. Policies match on those roles.
  • Not every table starts locked. Tables made in the dashboard have RLS on by default. Tables made with SQL, including every migration your agent writes, start with it off.

How do you enable RLS in Supabase?

Run alter table … enable row level security on the table, or switch it on in the dashboard, then add policies. Until a policy allows something, the API returns nothing for that table.

  1. In the dashboard: open Database > Policies and select Enable RLS for the table.
  2. In SQL or a migration: alter table public.projects enable row level security;
  3. Revoke the client roles' grants and grant back only what each needs (next section).
  4. Write a policy per command for anon or authenticated.
  5. Add a test under supabase/tests/ and run supabase test db.

Prefer the migration. A switch flipped in the dashboard is not in your repository, so your local database and the next environment won't have it.

Should RLS be enabled?

Yes, on every table in a schema the Data API exposes, which includes public by default. Supabase's docs put it in a warning: a table in an exposed schema without RLS is readable and writable by any role with a grant on it. The Security Advisor flags such tables. Only a table in a schema the API never exposes can do without it.

Does the service role key bypass RLS?

Yes. The legacy service_role key and the newer secret keys (sb_secret_…) both act as the service_role Postgres role, which has the BYPASSRLS attribute, so no policy applies to them.

As of October 2026, Supabase's API keys docs recommend publishable keys (sb_publishable_…) and secret keys over the JWT-based anon and service_role keys, which it plans to deprecate by the end of 2026. A secret key refuses to work from a browser: Supabase answers 401 when the User-Agent looks like one. One detail is easy to miss: the RLS guide says a secret key bypasses RLS only when the request carries no user access token. Send a user's token along and the request runs under that user's policies.

Keep secret keys on the server, and check permissions in code wherever you use one. Anon key vs service role key covers which key goes where.

How do you disable RLS in Supabase?

With alter table public.projects disable row level security;. Do it only for a table nobody should reach through the API, because of what it exposes.

With RLS off, the table's grants are the only protection left. On projects that still give anon and authenticated the default grants, that means anyone holding your publishable key, which ships in every browser bundle and phone app, can read, insert, change and delete every row through the Data API. If a policy is blocking something you expected to work, fix the policy or the grant instead. If server code needs to skip the rules, give it the secret key rather than turning them off for everyone.

Grants decide the command, policies decide the rows

Every request passes two checks. A grant decides whether a role may run select, insert, update or delete on the table at all. A policy then decides which rows that command touches. A missing grant fails with error 42501 before any policy runs.

On existing projects, Supabase's API security docs say a new table in public is granted those four commands for anon, authenticated and service_role automatically, and that Supabase is moving that default to opt-in. Adding policies doesn't take grants back, so don't depend on either default: write the grants yourself, next to the table. TRUNCATE ignores row security entirely, so a role that holds it can empty the table.

The kit starts every table from nothing. apps/web/supabase/schemas/00-privileges.sql strips the default TRUNCATE, REFERENCES and TRIGGER privileges, and each table revokes everything before granting back only what it needs. Say each project belongs to a team:

create table public.projects (
  id uuid primary key default gen_random_uuid(),
  account_id uuid not null references public.accounts (id) on delete cascade,
  name varchar(255) not null
);

alter table public.projects enable row level security;

revoke all on public.projects from anon, authenticated, service_role;
grant select, insert, update, delete on public.projects to service_role;
grant select, insert, delete on public.projects to authenticated;
grant update (name) on public.projects to authenticated;

The update grant lists only name. A user who tries set account_id = '<another team>' gets permission denied, whatever the policies say.

Supabase row level security policies: one per command

using checks rows that already exist, for select, update and delete. with check checks the row being written, for insert and update. Write four policies rather than one for all, which hides what each rule was meant to cover.

create policy projects_select on public.projects
  for select to authenticated
  using (public.has_role_on_account(account_id));

create policy projects_insert on public.projects
  for insert to authenticated
  with check (public.has_role_on_account(account_id));

create policy projects_update on public.projects
  for update to authenticated
  using (public.has_role_on_account(account_id))
  with check (public.has_role_on_account(account_id));

create policy projects_delete on public.projects
  for delete to authenticated
  using (public.has_role_on_account(account_id, 'owner'));

Members can read, add and rename the team's projects; only owners can delete them. What catches people out:

  • Name the role. Without to, Postgres applies the policy to PUBLIC, which means every role, anon included.
  • Updates need a select policy too. Without one, an update won't work as expected.
  • Give updates both clauses. using picks the rows a user may change; with check stops the changed row from landing in another tenant.
  • Watch for all on deletes. A delete consults only using. As a comment in the kit's 16-storage.sql notes, one for all policy that lets members view the team logo would let any member delete it too.

Multi-tenant RLS: check membership, not the user

For data one person owns, user_id = (select auth.uid()) is enough. Team data asks a different question: is the caller a member of the team that owns this row? The answer lives in a membership table, which has policies of its own.

That is where recursion bites. When a policy on the membership table reads the membership table, Postgres raises 42P17, "infinite recursion detected in policy for relation". Supabase's fix is a security definer function: it runs as its owner, so it reads the membership table without running that table's policies. The kit's helper, from 05-memberships.sql:

create or replace function public.has_role_on_account(
  account_id uuid,
  account_role varchar(50) default null
) returns boolean
language sql
security definer
set search_path = ''
as $$
  select exists(
    select 1
    from public.accounts_memberships membership
    where membership.user_id = (select auth.uid())
      and membership.account_id = has_role_on_account.account_id
      and (membership.account_role = has_role_on_account.account_role
        or has_role_on_account.account_role is null)
  );
$$;

Set search_path = '' on functions like this and schema-qualify every name inside. Supabase warns that a security definer function in an exposed schema can be called over the API with its owner's rights. The kit keeps its helpers in public deliberately, so each must be safe to call directly; this one only reports on the caller's own memberships. For tables that personal accounts can own too, the kit's policies add account_id = (select auth.uid()) or in front.

Why not put team ids in the JWT and skip the lookup? A token is only as current as its last refresh: Supabase's docs note that someone you remove from a team keeps the old claim until their token refreshes. A table lookup applies the change on the next request. If you do use claims, use app_metadata, never user_metadata, which users can edit.

Keep Supabase RLS fast

Policies run against every row a query considers. From Supabase's RLS performance guide, these matter most for tenant data:

  • Wrap auth calls in select. (select auth.uid()) is computed once per statement instead of once per row.
  • Index what your policies filter on, which in a multi-tenant app is nearly always account_id.
  • Filter in the query too. .eq('account_id', accountId) repeats the policy, but it gives the planner a better plan.
  • Measure row-dependent helpers. has_role_on_account(account_id) takes the row's value, so it runs per row. On a large table, check explain analyze and try the set form, which finds the caller's teams once:
using (
  account_id in (
    select account_id from public.accounts_memberships
    where user_id = (select auth.uid())
  )
)

Test it by signing in as the wrong tenant

A policy you haven't attacked is a guess. pgTAP lets you write tests in SQL, and supabase test db runs them against your local database. One behaviour decides how you write them: a blocked update or delete raises no error, because Postgres silently skips the rows the policy hides. So run the write as the outsider, then read the row back as a member:

-- setup not shown: team "acme", its member, one project
-- named "Launch plan", and a stranger outside the team
select test_helpers.authenticate_as('stranger');

select lives_ok(
  $$ update public.projects set name = 'Mine now'
     where name = 'Launch plan' $$,
  'The update runs but matches no rows'
);

select test_helpers.authenticate_as('member');

select is(
  (select name from public.projects
    where account_id = test_helpers.get_account_id_by_slug('acme')),
  'Launch plan',
  'The project name did not change'
);

The outsider's where filters on the name for a reason: a lookup helper would run under the outsider's own policies, return null, and make the test pass without testing anything.

The kit has 23 pgTAP files in apps/web/supabase/tests/database, run with pnpm supabase:web:test. 13 of them sign in as one user and try to read or change another account's data. schema-conditions.test.sql raises an error for any table in public with RLS off, so a table your agent forgets to lock fails the suite. After any database change, the root AGENTS.md sends your agent to the /rls-review skill, which writes attack tests like these.

FAQ

Is RLS on by default in Supabase?

Only for tables made in the dashboard. Tables made with SQL or migrations start with it off, and the Security Advisor flags any public table without it.

Do views follow RLS?

Not by default, because they usually run as the postgres user that created them. On Postgres 15 and later, create them with (security_invoker = true), as the kit's views in 15-account-views.sql are.

Does RLS apply to database functions?

Not to the function call itself. Grant execute only to the roles that need it, and remember a security definer function runs with its owner's rights, so the queries inside it usually skip RLS.

Do I still need permission checks in server code?

Yes, wherever you use a secret key, because that client skips every policy. In the kit, getSupabaseServerAdminClient() appears only in server code, such as the payment webhooks, invitations and the admin panel.

The full reference is in the docs: Row-level security and Database tests. How the kit keeps one customer's data away from another's lists what the kit locks down and where its limits are.

Live demo

Resets hourly

Get the kit