Skip to content
ShipSafeStack

Checklist

Supabase RLS policy examples

Short answer. These are worked Supabase row level security policies: own rows, public read with owner write, team membership, a role from app_metadata, authenticated-only, and storage objects. Each one says when to use it and the mistake that undoes it. The secret key bypasses all of them.

Updated 11 Nov 2026

This is a practical checklist, not a guarantee. It is not a penetration test, a compliance audit, or proof that an app is safe to launch.

This page is a copy-paste reference. The check you run after the policies exist is the Supabase row level security checklist. The grants-first setup for a project an AI builder just made is Supabase RLS for an AI-built app. Official text is Supabase's Row Level Security guide and Postgres CREATE POLICY.

Rename tables and columns to yours. A policy does not remove a grant. If anon still has insert, a careful policy will not stop that insert. The setup guide walks through revoke, then grant, then policy.

The publishable key and the anon key may sit in the client. What they can read is the policies. That difference is Is the Supabase anon key safe to expose?.

Enable RLS on the table

Use this on every table the Data API can reach, including tables created in the SQL editor or by a migration. Supabase says tables created in the Dashboard have RLS on by default. Tables created in SQL do not.

alter table public.notes enable row level security;

When to use it: before any policy. Postgres applies policies only after RLS is enabled. With RLS on and no policy that applies, Postgres assumes a default deny, so the publishable key sees no rows.

Common mistake: leaving RLS off because the query returned nothing and calling that a fix. With RLS off, a role that still has a grant can read and write every row.

Users read and write only their own rows

Use this when each row belongs to one account, such as a profile or a private note. Supabase's profiles example is four policies, one per operation. Postgres does not accept several operations in one for clause.

alter table public.notes enable row level security;

revoke all on table public.notes from anon, authenticated;
grant select, insert, update, delete on table public.notes to authenticated;

create policy "Users read their own notes"
on public.notes
for select
to authenticated
using ( (select auth.uid()) = user_id );

create policy "Users insert their own notes"
on public.notes
for insert
to authenticated
with check ( (select auth.uid()) = user_id );

create policy "Users update their own notes"
on public.notes
for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

create policy "Users delete their own notes"
on public.notes
for delete
to authenticated
using ( (select auth.uid()) = user_id );

create index notes_user_id_idx
on public.notes
using btree (user_id);

When to use it: the caller should see and change only rows whose user_id is their id.

using and with check are different. Postgres checks existing rows against USING. A false or null result hides the row and does not raise. New rows from INSERT or UPDATE are checked against WITH CHECK. A false or null result raises an error and aborts the statement. INSERT policies take WITH CHECK only. SELECT and DELETE policies take USING only. An UPDATE policy can take both: USING picks the existing row, WITH CHECK limits the row after the write. If you omit WITH CHECK on an update, Postgres uses the USING expression for both. Supabase also says an UPDATE needs a SELECT policy, or the update does not behave as you expect.

(select auth.uid()) is the form Supabase documents. Wrapping auth.uid() in select lets the planner run it once per statement instead of once per row. The same wrap works for auth.jwt() and for a security definer helper, and only when the result does not depend on the row. Index the column the policy filters on. A btree index is usable when that column is the first column of the index. A primary key on (team_id, user_id) does not index user_id.

Common mistake: one for select policy and no insert, update, or delete policy. The signed-in user can read their row and cannot write it. Another mistake is an update policy with USING only, so a user who can update the row can also set user_id to someone else. Put the same check in WITH CHECK.

auth.uid() is null when the request has no user. null = user_id is never true, so a signed-out request does not match. Supabase still recommends writing the intent, auth.uid() is not null and auth.uid() = user_id, when you are not also limiting the policy with to authenticated.

Public read, owner write

Use this for rows anyone may read, such as a published post, while only the author may change them. Supabase's example of a public read is to anon, authenticated with using (true).

alter table public.posts enable row level security;

revoke all on table public.posts from anon, authenticated;

grant select on table public.posts to anon, authenticated;
grant insert, update, delete on table public.posts to authenticated;

create policy "Anyone can read posts"
on public.posts
for select
to anon, authenticated
using ( true );

create policy "Owners insert posts"
on public.posts
for insert
to authenticated
with check ( (select auth.uid()) = owner_id );

create policy "Owners update posts"
on public.posts
for update
to authenticated
using ( (select auth.uid()) = owner_id )
with check ( (select auth.uid()) = owner_id );

create policy "Owners delete posts"
on public.posts
for delete
to authenticated
using ( (select auth.uid()) = owner_id );

When to use it: the table is meant to be public. Supabase says to anon using (true) gives every signed-out visitor every row that role can already reach through its grants.

Common mistake: copying using (true) onto a table of private rows, or granting insert to anon because the read policy used anon. Reads and writes are separate grants and separate policies.

Team membership

Use this when a row belongs to a team and any member may read it, not only the person who created it. A policy that reads the membership table, while the membership table's policy reads back, raises Postgres 42P17 (infinite recursion). Supabase breaks that cycle with a security definer function in a schema that is not exposed through the Data API. The function runs as its owner. On Supabase that owner is postgres, which has bypassrls, so the membership lookup does not re-enter the other policy.

create schema if not exists private;

create or replace function private.is_team_member(team uuid)
returns boolean
language sql
security definer
set search_path = ''
stable
as $$
  select exists (
    select 1
    from public.team_members
    where team_id = team
      and user_id = (select auth.uid())
  );
$$;

revoke execute on function private.is_team_member(uuid) from public, anon;
grant usage on schema private to authenticated;
grant execute on function private.is_team_member(uuid) to authenticated;

alter table public.projects enable row level security;

revoke all on table public.projects from anon, authenticated;
grant select, insert, update, delete on table public.projects to authenticated;

create index team_members_user_id_idx
on public.team_members
using btree (user_id);

create policy "Members read projects"
on public.projects
for select
to authenticated
using ( (select private.is_team_member(team_id)) );

create policy "Members insert projects"
on public.projects
for insert
to authenticated
with check ( (select private.is_team_member(team_id)) );

create policy "Members update projects"
on public.projects
for update
to authenticated
using ( (select private.is_team_member(team_id)) )
with check ( (select private.is_team_member(team_id)) );

create policy "Members delete projects"
on public.projects
for delete
to authenticated
using ( (select private.is_team_member(team_id)) );

When to use it: membership, not a single owner column, decides access. A member who is not the owner should be allowed. A non-member should not.

Set search_path = '' and schema-qualify every name inside the function. Without a pinned path, a caller can point an unqualified name at their own object and run it as the function owner. Never put the function in a schema listed under Exposed schemas. Supabase says a security definer function there is callable over the Data API with the creator's privileges.

Common mistake: an exists subquery from projects into team_members and another from team_members back into projects. That is the recursive pair. The other mistake is a membership primary key on (team_id, user_id) and a policy that filters on user_id with no separate index.

A role from app_metadata

Use this when the role lives in the JWT, not in a table you want the policy to scan. Supabase's example stores team ids in app_metadata and reads them with auth.jwt().

create policy "User is in team"
on public.projects
for select
to authenticated
using (
  team_id in (
    select auth.jwt() -> 'app_metadata' -> 'teams'
  )
);

When to use it: you already keep authorization data in raw_app_meta_data. Supabase says that column cannot be updated by the signed-in user, so it is the place for authorization data. raw_user_meta_data can be updated by the user through supabase.auth.update(). Do not use user_metadata to decide who may read or write a row.

The JWT is not updated the moment you change app_metadata. Supabase says auth.jwt() keeps the old claim until the user's JWT is refreshed. Cookie-based auth also has a size limit. Some browsers allow 4096 bytes per cookie, so a large app_metadata array may not fit.

Common mistake: auth.jwt() -> 'user_metadata' -> 'role' as the authorization check. The user can edit that claim. Removing someone from a team in app_metadata and expecting the next request to notice, before their token refreshes, is the other one.

Supabase's JWT reference says the role claim is anon, authenticated, or service_role. That claim is what the to clause matches. It is not a substitute for an application role you invented inside user_metadata.

Restrict the policy to authenticated

Use to authenticated when the rule is only for a signed-in session. Supabase maps a request with no user to the anon Postgres role, and a signed-in request to authenticated. An anonymous Auth user still uses the authenticated role. Tell those apart with the is_anonymous claim, not with to anon.

create policy "Signed-in users read their notes"
on public.notes
for select
to authenticated
using ( (select auth.uid()) = user_id );

When to use it: you want the planner to stop before it evaluates the expression for anon. Supabase shows the same policy without to authenticated and says the to clause keeps that expression from running for anon.

A policy with no to clause applies to PUBLIC, which is every role. to anon, authenticated using (true) is the public-read shape from the section above, and only for data that is meant to be public.

Common mistake: to anon because the client uses the anon key. The anon key is the API key. The Postgres role becomes authenticated after sign-in. A policy that names only anon does not cover signed-in users.

Storage object policies

Use policies on storage.objects when a bucket should not accept every upload. Supabase says Storage does not allow uploads without a policy. The INSERT policy is what an upload needs. Overwriting with upsert also needs SELECT and UPDATE.

storage.foldername(name) returns the folder path as an array. For an object at public/subfolder/avatar.png the first element is public. Supabase's example that keys the folder by the user id compares that element to auth.jwt() ->> 'sub', which is the same id as auth.uid().

create policy "Users upload into their folder"
on storage.objects
for insert
to authenticated
with check (
  bucket_id = 'avatars'
  and (storage.foldername(name))[1] = (select auth.jwt() ->> 'sub')
);

create policy "Users read their objects"
on storage.objects
for select
to authenticated
using (
  bucket_id = 'avatars'
  and (select auth.jwt() ->> 'sub') = owner_id
);

When to use it: files in a bucket, not rows in public. Supabase says to change objects through the Storage API. The storage tables hold metadata. Deleting a metadata row does not delete the object in the storage provider.

The service key bypasses Storage RLS as well. Supabase says to send it only from a trusted server.

Common mistake: an insert policy with with check (true), which is the starting example in the Storage access-control page, left in place. The other is a private table with a bucket that still allows a public read. The checklist calls that out.

Test as anon and as authenticated

Use this after each policy, in the SQL editor or in a transaction you roll back. Supabase's RLS guide switches identity with set local role and set local request.jwt.claim.sub. The pgTAP testing guide uses the same pair and says to run each assertion as anon or authenticated, not by reading pg_policies.

begin;

set local role anon;
select * from public.notes;

set local role authenticated;
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
select * from public.notes;

rollback;

When to use it: you want the policy evaluated as the API would evaluate it. set local lasts for the transaction. A using filter that hides a row returns no row and no error. A missing grant or a with check failure raises 42501.

Repeat with a second user id. The second id should not read, change, or delete the first user's row. Supabase's testing guide says a check that only lists policies can pass while the policy is still wrong, and that lives_ok on a write passes when the write matched zero rows.

Common mistake: running the query as postgres in the SQL editor and treating the result as what the anon key will see. postgres is not the API role.

The service role bypasses these policies

A secret key authorizes the service_role Postgres role, which has bypassrls. Supabase says to use that key for administrative work and never in the browser. The legacy JWT service_role key is the older form of the same bypass. Prefer the secret key, and keep it on the server.

Supabase also says the bypass applies only when the request does not carry a user access token. A client initialized with the secret key that still sends the user's token runs as that user, under the policies.

If that key is in a bundle, a repo, or a chat, follow Service role key leaked? What to do. The launch list that includes this database is the pre-launch checklist. How Supabase's policies compare with Firebase rules is Supabase vs Firebase.

Run a free passive check on the public URL when the app is deployed. It can see a service-role JWT in a public script. It cannot see whether these policies exist.

FAQ

Does one policy cover select and insert?

No. Postgres applies a policy to the command you name. A for select policy does not accept inserts. Write a separate policy for select, insert, update, and delete. Insert uses with check. Select and delete use using. Update should set both, so the caller cannot reassign the owner column.

Can I store the role in user_metadata?

No. Supabase says raw_user_meta_data can be updated by the signed-in user, and that an RLS policy which relies on user_metadata is a security issue. Put authorization data in app_metadata, or in a table the user cannot write. Remember that auth.jwt() keeps the previous app_metadata until the token refreshes.

Does the service role key ignore these policies?

Yes, when the request does not also send a user access token. The service_role role has bypassrls. That is why the secret key stays on the server. A publishable or anon key does not bypass the policies. It can only do what the grants and the policies allow.

Sources

Checked 11 Nov 2026.

Informational only. This page describes checks and vendor documentation. It is not a security audit, a penetration test, or a certification, and it is not a guarantee.

Newsletter

A short weekly note on new tools and security checklists. No spam, unsubscribe anytime.