Skip to main content
This page is a recipe collection. Each pattern is a working SQL policy you can paste into the Studio SQL editor or your migrations, with notes on when to use it, common variants, and the gotchas that bite people. For the conceptual underpinning, see Row Level Security. For testing without a frontend, see RLS Testing. Every example here assumes:
  • The table is in public, RLS is enabled (ALTER TABLE ... ENABLE ROW LEVEL SECURITY), and you want to add policies on top.
  • You’re signing in users through GoTrue, so auth.uid() returns the user’s id.
  • You want to expose the table directly to clients via PostgREST. (If you’re only ever hitting it from a backend with the Service Role key, you don’t need any of this; service_role bypasses RLS.)
Step zero: enable RLS, then add policies. ALTER TABLE my_table ENABLE ROW LEVEL SECURITY; flips the default to “deny all.” If you then add no policies, the table is unreadable by anyone except service_role. Always pair the ENABLE with the policies in a single migration.

Pattern 1: Each user sees and edits only their own rows

The most common pattern. A todos table where every row has an owner_id column, and users can only touch their own rows.
Why the WITH CHECK on UPDATE. USING decides which rows the policy applies to before the update; WITH CHECK validates the result. Without WITH CHECK, a user could change owner_id from their own id to anyone else’s mid-update and the policy would still pass. Always pair USING + WITH CHECK on UPDATE policies. Variant: let the server set owner_id. Instead of trusting the client to send owner_id, default it from the session:
Now INSERT requests that omit owner_id get the caller’s id automatically, and your WITH CHECK (owner_id = auth.uid()) ensures they can’t override it.

Pattern 2: Public read, auth-only write

A blog. Anyone, including unauthenticated visitors, can read posts. Only the author can create, edit, or delete their own posts.
Important: RLS policies are additive (OR-combined) within the same role. Both the posts_public_read and posts_author_read_drafts policies apply when a signed-in user reads, so they see all published posts AND their own drafts. That’s the desired behavior here.

Pattern 3: Tenant isolation (multi-org SaaS)

You’re building a SaaS where each user belongs to one or more organizations and rows are scoped per organization. A documents table where users only see documents in orgs they’re a member of. You need a members table that says who belongs to which org:
Then the documents table policy uses a subquery against members:
Performance gotcha. That IN (SELECT ...) runs once per row scanned at worst. Add an index on members(user_id, org_id) and Postgres will turn it into a hash semi-join. If documents gets large, also index documents(org_id). For very high cardinality, denormalize: stuff the user’s allowed org_ids into the JWT (via a GoTrue hook) and read them from auth.jwt() -> 'org_ids' directly, which avoids the join entirely.

Pattern 4: Role-based access (admin / member)

Extending the tenant pattern: only org admins can delete documents, members can read and create. Two reasonable approaches. Option A: encode role in the policy expression. Re-use the members.role column:
Option B: encode role in the JWT. If GoTrue is minting tokens with custom claims (e.g., via a Postgres function hook on sign-in), you can stash a {"role": "admin"} claim and check it directly:
Option A is the right default: roles are dynamic, change without re-signing the user in, and there’s a single source of truth. Option B is faster (no subquery) but requires re-issuing tokens when roles change. Use it for things that genuinely won’t change mid-session, like whether the user is verified at all.

Pattern 5: Soft delete

Instead of physically deleting rows, you mark them with deleted_at. Active queries should hide them; admins should still see them. The trick is to filter deleted_at IS NULL in the policy itself, so callers never have to add it.
Callers now issue PATCH /rest/v1/posts?id=eq.{id} with {"deleted_at": "<now>"} instead of DELETE. The policies hide the row from subsequent reads. Variant: let admins see deleted rows. Add a separate policy for the admin role:
Because policies OR-combine, admins see both active rows (via the regular policy) AND soft-deleted ones (via this one). Regular users still only see active rows.

Patterns worth knowing about

A few that come up but don’t need full recipes:
  • Force RLS for the table owner. By default the table owner (service_role and the project Postgres user) bypasses RLS. To make RLS apply even to the owner (useful for safety in shared environments), use ALTER TABLE ... FORCE ROW LEVEL SECURITY. Don’t use this on the ai.* tables; the platform’s backend assumes service-role bypass.
  • Permissive vs restrictive policies. All policies are PERMISSIVE by default, so they OR together. RESTRICTIVE policies AND together with the result. Useful when you want to layer a “no row may be deleted on Sundays” check on top of existing permissive policies without rewriting them.
  • Functions in policy expressions. Postgres caches policy expression results per row per query. A SELECT 1 FROM members WHERE ... subquery is fine; an HTTP call from inside a policy (via pg_net) is not, and it’ll run thousands of times. Keep policy expressions cheap and deterministic.

Next steps

RLS Testing

Test policies in the SQL Editor or psql before deploying.

RLS Model

How JWTs, roles, and auth.uid() compose under the hood.

Querying the ai schema

The default RLS posture on ai.* and when to tighten it.

ai schema recipes

PostgREST patterns for analytics, bulk ops, and embeds.