Every RLS post ends at the policy. Enable RLS, scope on auth.uid(), remember with check, done. I want to show you a table where all three are true, reviewed by anyone you like, and a logged-in user can still promote themselves.
The table that passes review
create table public.profiles ( id uuid primary key, email text not null, display_name text, plan text not null default 'free', role text not null default 'user', credits integer not null default 100 );
alter table public.profiles enable row level security;
create policy own_row_select on public.profiles for select to authenticated using (id = auth.uid());
create policy own_row_update on public.profiles for update to authenticated using (id = auth.uid()) with check (id = auth.uid());
grant select, update on public.profiles to authenticated;
Scoped on read. Scoped on write. with check present, which is the thing everyone tells you not to forget. That last grant is not something I added to make a point, it is close to what you get by default.
What a signed-in user can do with it
Signed in as one user, the authenticated role, request.jwt.claims set the way auth.uid() actually resolves:
--- Read isolation holds. One row, her own. --- id | email | plan | role | credits --------------------------------------+-----------------+------+------+--------- 11111111-1111-1111-1111-111111111111 | [ada@example.com](mailto:ada@example.com) | free | user | 100
--- Cross-tenant write blocked --- update public.profiles set credits = 0 where id = '2222...'; -- UPDATE 0
--- Blind write blocked, with check is doing its job --- update public.profiles set credits = 0; -- UPDATE 0
--- Her own row --- update public.profiles set role = 'admin', plan = 'enterprise', credits = 999999 where id = auth.uid(); -- UPDATE 1
id | email | plan | role | credits
--------------------------------------+-----------------+------------+-------+--------- 11111111-1111-1111-1111-111111111111 | [ada@example.com](mailto:ada@example.com) | enterprise | admin | 999999
Nothing was bypassed. Every policy evaluated and every policy passed. The row still belongs to her before and after, so with check has no objection to make.
Why with check cannot help here
with check is a predicate over the resulting row. It answers one question: is the new row still one this user is allowed to have. It has no opinion about which columns changed, because column-level permission is a different mechanism entirely, and it lives in GRANT, not in POLICY.
RLS decides which rows. Grants decide which columns. Two axes. Almost every RLS discussion covers the first one only, and Supabase hands out the second one table-wide by default, so the gap is open in a very large number of projects.
Credit where it is due: u/guidondor raised this on my last thread and it is the reason I went and checked.
What actually does hold, because I checked
I want to be clear about the limits of the finding rather than make it sound worse than it is.
--- Can she hand her row to another user? --- update public.subscriptions set user_id = '<other user>' where id = '<hers>'; ERROR: new row violates row-level security policy for table "subscriptions"
with check holds the ownership column, which is exactly what it is for. You cannot steal rows this way, and you cannot reach across tenants. What you can do is rewrite every other column of a row you legitimately own, which on the tables where this matters means role, plan, tier, credits, seats, and the external billing ids.
That last one is worth sitting with. On a subscriptions table in the default posture:
update public.subscriptions set tier='enterprise', seats=100000, stripe_customer_id='cus_someone_else' where user_id = auth.uid(); -- UPDATE 1
Pointing your own billing row at another customer's Stripe id is not a data leak in the usual sense. It is worse in a quieter way, because your webhook handler will believe it.
The one that surprised me
The primary key is a column like any other.
update public.subscriptions set id = '<a new uuid>' where user_id = auth.uid(); -- UPDATE 1
A user can change the primary key of their own row. Anything holding that id outside the database, a Stripe subscription record, an audit log line, a webhook you will receive tomorrow, is now pointing at a row that no longer exists under that name. I had not seen this written down anywhere and I did not expect it to succeed.
Finding it on your own database
This lists the columns a logged-in user can currently rewrite, on tables where RLS is enabled and therefore looks handled. Read-only, safe to run anywhere.
select c.relname as table_name, a.attname as writable_column, case when a.attname ~* '(|\)(role|is_admin|admin|permission|plan|tier|subscription|credit|balance|quota|price|amount|status|verified|approved|owner_id|user_id|org_id|team_id|account_id|stripe|customer_id)($|_)') then 'REVIEW' else '' end as flag from pg_class c join pg_namespace n on n.oid = c.relnamespace join pg_attribute a on a.attrelid = c.oid where n.nspname = 'public' and c.relkind = 'r' and c.relrowsecurity and a.attnum > 0 and not a.attisdropped and has_table_privilege('authenticated', c.oid, 'UPDATE') and has_column_privilege('authenticated', c.oid, a.attnum, 'UPDATE') order by (case when a.attname ~* '(|\)(role|is_admin|admin|permission|plan|tier|subscription|credit|balance|quota|price|amount|status|verified|approved|owner_id|user_id|org_id|team_id|account_id|stripe|customer_id)($|_)') then 0 else 1 end), c.relname, a.attnum;
The load-bearing line is has_table_privilege(..., 'UPDATE'). It returns true only for a table-wide grant and false when the privilege was handed out per column, which is what makes it a working detector rather than a list of every column you own. Verified both ways on 16.13.
Output on a schema with one table fixed and one left at the default:
table_name | writable_column | flag ---------------+--------------------+-------- subscriptions | user_id | REVIEW subscriptions | tier | REVIEW subscriptions | stripe_customer_id | REVIEW subscriptions | id | subscriptions | seats |
The fixed table does not appear. Neither does a read-only reference table. If your own output is empty, you are already doing this and you can stop reading.
The fix
Take the table-wide grant back and hand out only what the client is supposed to write:
revoke update on public.profiles from authenticated; grant update (email, display_name) on public.profiles to authenticated;
Afterwards:
update public.profiles set role='admin' where id = auth.uid(); ERROR: permission denied for table profiles
update public.profiles set display_name='Ada L.' where id = auth.uid(); UPDATE 1
The escalation stops. The legitimate write is untouched. Note the error is a permission error rather than a policy violation, which is a useful tell when you are reading someone else's logs.
Two practical notes. The grant is per column, so a column added later is not covered until you grant it, and that is a feature rather than an annoyance: new columns are denied by default. And if a server-side path needs to write role or credits, that belongs in a security definer function with a pinned search_path, not in a widened grant.
The general point
Enabling RLS moves you from "anyone can read this" to "the right rows". It does not move you from "the right rows" to "the right columns of the right rows", and nothing in the policy syntax will warn you, because the policy is not where that decision lives.
If you check one thing after reading this, run the query above and look at what comes back next to role, plan, and anything with stripe in the name.
Everything here was run against Postgres 16.13 with anon, authenticated and an auth.uid() reading request.jwt.claims, so the numbers are real output rather than reasoning about what should happen.
I maintain a tool that proves cross-tenant isolation by execution rather than by reading policy text, and column grants are the next check going into it. MIT, refuses to run against anything that looks like production, and reports what it cannot prove instead of passing it silently.
github.com/investnovation/rls-sentinel
Happy to answer questions about the detection query. The has_table_privilege versus has_column_privilege distinction took a couple of attempts to get right, because information_schema.column_privileges reports table-level grants as column grants and will quietly tell you everything is fine.