Most authorization bugs share a shape: a code path that forgot to check. A new endpoint, a refactored query, a background job — somewhere, data flowed without passing the guard. The industry's answer has been discipline: linters, review checklists, middleware. Postgres offers a more radical answer: make the database itself refuse.
Default-deny as a foundation
Row-Level Security inverts the failure mode. With RLS enabled and no policy granted, a table returns nothing — to anyone. Every read a user can perform exists because a policy explicitly permits it. Forgetting something now fails closed: the new endpoint that skipped the check returns an empty set instead of a data leak.
This is not free. Policies are code that lives in migrations, they need tests, and complex policies can wreck query plans. But the asymmetry is the point: the cost is paid in visible, reviewable SQL, while the benefit is a vulnerability class that structurally cannot occur.
Claims, not lookups
The pattern that scales: encode roles into the JWT's app_metadata at auth time, and write policies against the claim — `auth.jwt() -> 'app_metadata' ->> 'role'`. Policies that join against a roles table re-run that lookup on every row; policies that read a claim are constant-time and, more importantly, legible. An auditor can read the policy and know the rule.
Writes deserve stricter treatment. In my architectures, public clients get no INSERT policy at all — writes flow through server routes holding the service-role key, where validation, rate limiting, and audit logging live. The client cannot write badly because the client cannot write.
Authorization cost per row
Reading a JWT claim vs. joining a roles table on every row (indicative)
The philosophy
Security by vigilance decays; security by construction compounds. RLS will not save an application from every mistake, but it relocates the permission boundary to the one place every query must pass. Design there.