Skip to content
CodeAndBuild LogoCodeAndBuild

Supabase

Supabase Row Level Security for a Multi-Tenant App

Turn on RLS, scope every row to the signed-in user or their team, and test the policies with the anon key.

CodeAndBuild Team9 min read
  • Supabase
  • Postgres
  • Security
  • Auth
On this page
  1. Tables that can express ownership
  2. One policy per command
  3. Test with the anon key, not the SQL editor
  4. Where the service role is allowed
  5. A review checklist

Supabase gives the browser a key on purpose. The anon key is not a secret, and anyone can call your project with it. The thing that keeps one customer out of another customer's rows is Row Level Security in Postgres. If RLS is off, the anon key can read the table. If RLS is on and you forgot a policy, the table looks empty. Both failures are quiet, which is why teams notice them in production.

This guide sets up a small multi-tenant shape: a profile, a team, a membership, and a notes table that belongs to a team. Policies use auth.uid(), which Supabase fills from the JSON Web Token on the request. The service-role key bypasses RLS. It stays on the server. The browser client uses the anon key and the user's session.

Tables that can express ownership

A policy can only filter on columns that exist. Add user_id or team_id at insert time and never trust the client to pick a different owner later. For a team product, membership is its own table. A note does not store an array of user ids. It stores a team id, and a policy checks that the current user has a membership row for that team.

supabase/notes.sqlsql
create table public.notes (
  id uuid primary key default gen_random_uuid(),
  team_id uuid not null references public.teams (id),
  author_id uuid not null references auth.users (id),
  body text not null,
  created_at timestamptz not null default now()
);

alter table public.notes enable row level security;

One policy per command

Postgres checks SELECT with USING, and INSERT with WITH CHECK. An UPDATE needs both: the row you can see, and the row you are allowed to write. Splitting them stops a member from moving a note to a team they do not belong to by changing team_id in the update payload.

supabase/notes-policies.sqlsql
create policy "members read notes"
on public.notes
for select
using (
  exists (
    select 1 from public.memberships m
    where m.team_id = notes.team_id
      and m.user_id = auth.uid()
  )
);

create policy "members insert notes"
on public.notes
for insert
with check (
  author_id = auth.uid()
  and exists (
    select 1 from public.memberships m
    where m.team_id = notes.team_id
      and m.user_id = auth.uid()
  )
);

Memberships need their own policies, or the exists() check above can recurse into a table nobody can read. A simple version lets a user select membership rows where user_id equals auth.uid(). Admins who must list every member of a team need a second policy that checks a role column. Write that policy on purpose. Do not disable RLS on memberships to make the join work.

Test with the anon key, not the SQL editor

The SQL editor in the dashboard usually runs as a privileged role. A query that returns every note there proves nothing about your app. Sign in as two users in two browsers, or use the Supabase client with each user's access token, and try to read the other team's id. You want an empty list or an error, never the other team's body.

  1. 01

    Create two users and two teams

    Put user A only on team A. Put user B only on team B. Insert one note on each team using the service role or a seed script.

  2. 02

    Read as user A

    Select from notes with A's session. You should see team A's note and not team B's note.

  3. 03

    Insert as user A into team B

    The insert should fail the WITH CHECK. If it succeeds, the policy is looking at the wrong column.

  4. 04

    Repeat after every policy edit

    A new column or a new role is a new test. Keep the two-user script next to the migration.

Where the service role is allowed

  • Webhooks that have already verified a signature, such as a billing provider telling you a subscription changed.
  • A nightly job that deletes data for a closed account, running in a worker you control.
  • Migrations and seeds. Never the browser bundle, and never a NEXT_PUBLIC variable.

If a route handler uses the service role, it must check the user itself. RLS will not help. Read the session with the anon client first, then perform the privileged write for that user id only. Passing a team id from the request body straight into a service-role query recreates the hole RLS was meant to close.

A review checklist

Every public table has RLS enabled. Every command you use from the client has a policy. Policies compare auth.uid() to a column, or to a membership row, and they do not use a value the user can forge. The service-role key is absent from client code. Two real users have been tested against each other's rows. That list is the difference between a demo that looks multi-tenant and a database that is.

More guides