RLS hides rows; column privileges hide columns and break select('*')

Revoke UPDATE on the table from authenticated and grant it back per column, and a member can no longer rewrite created_by. Clients must then name their columns.

Data API

· Chapter

30

·

2

min read

The answer. A policy decides whether a row is reachable; it says nothing about which columns of that row the caller may change. Once a user may update a ticket, they may update every column of it, including ownership and audit fields. Postgres column privileges close that: revoke the verb on the table from the role, then grant it back for the columns that are fair game. PostgREST honors the grants, so a request that touches a forbidden column fails outright. The cost is ergonomic. A role without privileges on some column cannot use select * on that table any more, which means every client query must list its columns, and the grants are per role, not per user, so "admins may edit status but members may not" still needs either a second role or the trigger approach. Use column privileges for the handful of columns that must never be client-writable; use a reset trigger when you want silent correction instead of an error.

The pattern.

revoke update on table public.tickets from authenticated;
grant update (title, description, status, assignee) on table public.tickets to authenticated;   -- not created_by, tenant, created_at
-- reads stay whole-table: grant select on table public.tickets to authenticated;
const { error } = await supabase.from("tickets").update({ created_by: 1 }).eq("id", id);
// error.code === "42501": permission denied for table tickets   (loud, unlike a filtered row)

Watch out.

  • The dashboard has a Column Privileges page (Database → Column Privileges) that shows the same grants; use it to audit, and migrations to change.
  • Column privileges never apply to DELETE; rows are the unit there.
  • Supabase calls this an advanced feature and steers most projects to RLS plus a roles table. Reach for it when the column list is short and stable.

Related: update-policies-have-two-halves-using-and-with-check · set-created-by-in-a-before-insert-trigger-never-from-the-client · expose-only-an-api-schema-to-postgrest