Supabase column-level security: why REVOKE does nothing
Supabase column-level security is plain Postgres column privileges, and a column REVOKE such as revoke update (role) or revoke select (email) does nothing while the role still holds a table-level grant, which Supabase's default Data API grants give anon and authenticated. Revoke the table-level privilege first, then grant back only the columns that role may use.
RLS filters rows, not columns, so when one column must stay private, developers reach for a column privilege. The statement they write looks right and changes nothing. The Postgres REVOKE reference says so:
"...if a role has been granted privileges on a table, then revoking the same privileges from individual columns will have no effect."
Does Supabase have column-level security?
Yes, but it is not part of RLS. Supabase's Column Level Security page documents ordinary Postgres privileges: GRANT and REVOKE with a column list. Two layers decide access:
- Table-level privileges cover every column of the table.
- Column-level privileges cover only the columns named.
A role can use a column if it holds the privilege at either level, as the GRANT reference notes, so a column revoke never narrows a table grant.
Why does REVOKE on a column do nothing in Supabase?
Because the role you are revoking from usually holds the privilege on the whole table, and a column revoke cannot carve a hole in a table grant. Supabase's docs say where that table grant comes from: "By default, our table will have a table-level UPDATE privilege, which means that the authenticated role can update all the columns in the table."
That default is the automatic Data API grant Supabase has given anon and authenticated on tables in public. A table that received it keeps it until you revoke it.
The docs page shows how easily this goes wrong. Its first example revokes the table-level UPDATE before granting columns. Its "Manage column privileges in migrations" step drops that line: it creates a posts table, adds an owner-only UPDATE policy, and runs only revoke update (title) on table public.posts from authenticated;. On a table with the default grant, title stays as writable as before.
Don't expect the migration output to warn you. The REVOKE reference documents warnings only for missing grant options, and notes "the owner is always treated as holding all grant options." Migrations normally run as the table's owner.
The escalation: a role column every user can write
A profiles table carries the user's role, the update policy scopes rows to their owner, and two column revokes are meant to protect email and role:
create table public.profiles (
id uuid primary key references auth.users (id) on delete cascade,
username text,
avatar_url text,
email text,
role text not null default 'user'
);
alter table public.profiles enable row level security;
-- Profiles are public, but email should not be.
create policy "profiles are public"
on public.profiles for select
using (true);
revoke select (email) on public.profiles from anon;
-- Users edit their own profile, but not their role.
create policy "users update own profile"
on public.profiles for update
to authenticated
using (auth.uid() = id);
revoke update (role) on public.profiles from authenticated;
- Warningsupabase/migrations/20261024120000_profiles.sql:12
Overly permissive RLS policy
Scope the policy to the requesting user, e.g. USING (auth.uid() = user_id). Reserve (true) for genuinely public, read-only data.
On default grants, both revokes are no-ops:
anonstill holds table-levelSELECT, so anyone with the public anon key reads every email in the table.authenticatedstill holds table-levelUPDATE. The policy only checks that the row is yours, so any signed-in user can runsupabase.from('profiles').update({ role: 'admin' }).eq('id', user.id)and promote themselves whereverprofiles.roleis trusted. Sign-ups are open by default, so "any signed-in user" means anyone with an email address.
The no-op shows up in published advice: a comment under a dev.to tutorial on Supabase user and admin roles suggests REVOKE UPDATE (is_admin) ON public.profiles FROM authenticated; for a table whose update policy is auth.uid() = id, with no table-level revoke. The update policy doesn't help: it checks who owns the row, not which columns changed. A WITH CHECK expression can constrain the new row's values, as the UPDATE policy WITH CHECK post shows, but that is a separate fix from the grant.
In the scan above, GuardLayer flags the using (true) read policy as a warning (the finding's full message in a scan report names the email column) and does not count revoke select (email) as mitigation; the rule only treats a table-level REVOKE SELECT or REVOKE ALL as taking read access away. What it does not catch is the role escalation. No GuardLayer rule flags a no-op column revoke today, so a migration containing only the update policy and revoke update (role) produces zero findings. Use the self-check below for that half.
How do I fix column-level security in Supabase?
Revoke the table-level privilege, then grant the columns the role may use. Order matters: a table-level REVOKE also removes that role's column privileges on the table, so revoke first and grant second, in the same migration:
-- Reads: take the whole-table SELECT away, give back the public columns.
revoke select on public.profiles from anon, authenticated;
grant select (id, username, avatar_url, role) on public.profiles to anon, authenticated;
-- Writes: same order. Users may edit their name and avatar, nothing else.
revoke update on public.profiles from authenticated;
grant update (username, avatar_url) on public.profiles to authenticated;
If clients insert their own profile row, do the same for INSERT, or users set role at creation instead.
Two consequences to plan for:
- No more
*. Supabase's docs warn that restricted roles cannot use the wildcard operator on the affected table. In supabase-js,.select()with no argument sends*, so name the columns:.select('id, username, avatar_url'). - New columns start private. With no table-level
SELECTleft, a column added later can't be read by these roles, or updated byauthenticated, until you grant it.
Confirm the result with has_column_privilege. Per the Postgres docs, it succeeds "either if the privilege is held for the whole table, or if there is a column-level grant", so it reports the real answer:
select
has_column_privilege('anon', 'public.profiles', 'email', 'SELECT') as anon_reads_email,
has_column_privilege('authenticated', 'public.profiles', 'role', 'UPDATE') as user_writes_role;
Both should be false. If either is true, the role still holds that privilege, most likely through a table-level grant.
The simpler fix, and the one Supabase's docs lean toward, is not needing column privileges at all. Don't copy email into a public table; it already lives in auth.users. Keep the role where the user has no write path: a roles table with no client write policy, or app_metadata, which users can't edit themselves. The user_metadata vs app_metadata post shows both.
What changes after October 30, 2026?
New tables stop getting the default grant, so the risk moves to the grant you write yourself. Under Supabase's changelog #45329, new tables in public get no Data API grants (rolling out to new projects from May 30, 2026, and applied to existing projects on October 30, 2026), while existing tables "keep their current grants." On a new table, a column revoke has nothing to revoke, and the first API call fails with permission denied for table until you grant something.
The changelog's suggested grant is table-level, grant select, insert, update, delete on public.your_table to authenticated;, and pasting it makes every column revoke on that table a no-op again. On a table with columns a role must not read or write, grant those privileges column by column instead. The Data API grant change post covers the rest of the switch.
Will Supabase's linter catch it?
Not today. A proposed lint, 0033 column_grant_overridden_by_table_grant (splinter PR #200, open as of October 24, 2026), would flag a column-level grant beside a table-level grant of the same privilege, such as grant select (id, title) on a table the role can already read in full. Its description states the limit: a plain column REVOKE against a table-level grant stores nothing in the catalog, so the lint has nothing to find. The database can tell you a role still holds the privilege; only your migration records that you meant to take it away.
A 60-second self-check
# Every column-level REVOKE in your migrations
grep -rniE "revoke\s+(select|insert|update|references)\s*\(" supabase/migrations/
It only matches one-line statements; if yours span lines, as in Supabase's docs, search for revoke alone.
For each hit, look for a table-level revoke of the same privilege from the same role on the same table, ahead of any column grants. If there isn't one, run the has_column_privilege query above for that column. true means the revoke is doing nothing.
FAQ
Does Postgres have column-level security?
Yes, as column privileges: GRANT SELECT (col), GRANT UPDATE (col) and the matching REVOKE. RLS policies only filter rows. Supabase uses the same Postgres feature and documents it as Column Level Security.
Why doesn't revoke update (column) work in Supabase?
On a table with the default grant, the role still holds table-level UPDATE, which covers every column. Revoke update on the table, then grant update (col, ...) for the columns you allow.
Can RLS hide a column? No. A policy decides which rows are visible, and every column of a visible row comes back. Use column privileges, or keep the column in a table the role can't read.
Will column privileges break select *?
Yes, for a role whose SELECT is limited to some columns: Postgres requires SELECT on every column a query uses. Name the columns in .select().
Does GuardLayer detect a no-op column REVOKE?
Not directly. It flags a public using (true) read policy on a table with an email column, and a column revoke doesn't clear that warning. No rule flags a no-op column revoke, so the revoke update (role) escalation gets no finding.
Catch this before it ships — free
Scan your files for this and 35 other Next.js + Supabase issues, with the exact fix inline. Connect GitHub and GuardLayer checks every push too — free on public repos.
No signup, no card. Raw files aren’t kept; only the report is saved.