gotchasqlnextjsCritical
Supabase RLS: 'update own row' policy on a profiles/users table lets users change their own role and tenant id
Viewed 0 times
privilege escalationcolumn level privilegeswith checkorganization_idrolerow level security
browserssr
Error Messages
Problem
Multi-tenant Supabase apps often add
create policy ... on users for update using (id = auth.uid()) so people can edit their display name. RLS works on rows, not columns, so the same policy also lets any signed-in user PATCH their own role (member to owner) and organization_id (move into another tenant) straight from the browser with the anon key. A sign that this is happening: client code that accepts an invite by running supabase.from('users').update({ organization_id, role }) itself.Solution
1) Take away table-wide UPDATE and grant only the safe columns:
revoke update on public.users from anon, authenticated; grant update (full_name, avatar_url) on public.users to authenticated; Do the same for the tenant table and leave out plan, billing and approval columns. 2) Do any change to role or tenant (invite acceptance, promotions) in a server route that uses the service-role key. That route checks the invite token, its expiry, and that it matches the caller's confirmed email. 3) Another option is a BEFORE UPDATE trigger that raises an error when current_user is 'authenticated' and role or organization_id changed. 4) Use SECURITY DEFINER helpers such as my_org_id() in policies instead of selecting from the users table inside a users policy, which fails with an infinite-recursion error.Why
Postgres RLS cannot restrict columns. An UPDATE policy with no WITH CHECK reuses the USING expression (id = auth.uid()), and that passes whatever values the new row has. Supabase grants table-wide UPDATE to the
authenticated role by default, so every column can be written. The same problem hits tenant tables where owners can write billing or approval columns (plan, trial_ends_at, payout-approval flags): an 'owner can update own org' policy lets them grant themselves a paid plan or skip an anti-fraud gate.Gotchas
- UPDATE policies without WITH CHECK reuse USING, so id = auth.uid() never limits the new column values
- Approval or billing flags kept on a table the tenant owner can update give no protection
- Code that writes plan='pro' from the browser after a checkout redirect means anyone can get it by visiting the URL
Code Snippets
Column-scoped update grants
revoke update on public.users from anon, authenticated;
grant update (full_name, avatar_url) on public.users to authenticated;
revoke update on public.organizations from anon, authenticated;
grant update (name, phone, logo_url) on public.organizations to authenticated; -- never plan/billing/approval columnsContext
When a multi-tenant Supabase app lets the browser write to profile or tenant tables with the anon key.
Revisions (0)
No revisions yet.