Supabase Row Level Security (RLS) Data Isolation
Production-ready Row Level Security patterns for a Supabase + Cloudflare Workers stack — the database-layer isolation primitive that makes cross-user data leaks impossible even when a route is buggy or someone talks to PostgREST directly. Covers the owner-isolation table skeleton (1:1 profile + one-to-many children), the five load-bearing RLS rules ((select auth.uid()) wrapping, TO authenticated, forward-set child subqueries, indexing policy columns, trusting only auth.uid()/app_metadata), the service_role (bypass RLS) vs user-JWT (RLS enforced) client split, server-side owner_id injection, public read-only tables whose writes funnel through service_role, and a two-token cross-access isolation test that must return 403/empty.
Copies a short instruction with your private download link. Paste it into your agent: it downloads the whole skill (the source code, the setup steps and the mistakes we already made for you) and builds the screen.
Works with
What it does
Filtering per-user data with an application-layer WHERE owner_id = ... clause is one bug away from a full horizontal-privilege-escalation leak. Forget the clause in one route, mistype a join, expose one service_role-backed endpoint without a manual filter — and every user can read every other user's rows. Worse, on a Supabase stack the client can talk to PostgREST directly (the REST endpoint is public), so "I only ever call my own backend" is not a security boundary at all.
Row Level Security (RLS) moves isolation into the database. Every row carries an owner; every policy is a predicate the database evaluates on every read and write. Get it right once and:
- Leaks become impossible by construction — even a buggy route, or a client hitting PostgREST directly with a valid but low-privilege JWT, cannot see or touch another user's rows. The database itself refuses them.
- Routes stay clean — with the user-JWT client you never hand-write
WHERE owner_id. Youselect("*")and RLS returns only the caller's rows. - Anonymous users are first-class — a Supabase anonymous sign-in issues a real signed JWT (
role: authenticated,is_anonymous: true), so anonymous users hit the sameTO authenticatedpolicies and safely read/write their own data (see auth-supabase-anonymous). - Defense in depth — RLS is the last and hardest line behind JWT verification and server-side
owner_idinjection; each layer independently prevents an IDOR.
This is the security core of the infra-supabase stack. Where infra-cdk isolates data with application-layer WHERE clauses over Aurora (the app is the only trusted boundary), Supabase pushes the boundary down to Postgres — read the two side by side. This skill assumes you already have the Workers + Supabase scaffolding from infra-supabase and the serviceClient / userClient factories from auth-supabase-anonymous; it focuses only on the RLS layer.
What you get
- ✓256 lines of source code, ready to paste
- ✓Architecture
- ✓Dependencies
- ✓Implementation
- ✓Testing RLS Isolation
- ✓Integration Checklist
- ✓Common Customizations
- ✓Known Pitfalls
- ✓Production Readiness Checklist
Architecture
RLS is enforced at the database, keyed off the caller's verified JWT. The Worker chooses which client to use, and that choice decides whether RLS applies:
Authorization: Bearer <user JWT>
iOS ──REST──▶ Worker (jose verifies sig + iss + aud) ──┐
│
┌──────────────────────────────────────────────┴─────────────────────────┐
│ │
▼ userClient(anon key + user JWT) serviceClient(service_role) ▼
PostgREST runs as role=authenticated, sub=<uid> PostgREST runs as role=service_role
│ │
▼ ▼
┌───────────────────────────────────────────┐ ┌──────────────────────────┐
│ Postgres — RLS ENFORCED, evaluated per row │ │ RLS BYPASSED │
│ using ( (select auth.uid()) = owner_id ) │ │ system operations ONLY: │
│ with check ( (select auth.uid()) = owner_id ) │ anon sign-in, profile init,│
│ → caller sees / writes ONLY its own rows │ │ public-table upsert, cleanup│
└───────────────────────────────────────────┘ └──────────────────────────┘Defense in depth — three independent guards, any one of which stops an IDOR:
| Layer | Guard | What it stops |
|---|---|---|
| 1. Middleware | jose verifies the JWT signature + issuer + audience: "authenticated" | Forged / tampered tokens (a hand-crafted {"sub":"<victim>"}) |
| 2. Route | owner_id is injected from authUser.userId, never read from the request body | A client claiming to own another user's data |
| 3. Database (RLS) | using / with check on (select auth.uid()) | Everything that slips past layers 1–2 — a buggy route, a forgotten filter, or a client hitting PostgREST directly |
The point of layer 3: layers 1–2 live in code you can get wrong. RLS lives in the database and is evaluated unconditionally. It is the reason a single missed WHERE clause is a no-op instead of a breach.Client roles (the split that decides whether RLS applies):
| Client | Credential | RLS | Use for |
|---|---|---|---|
userClient | anon key + the request's user JWT | ENFORCED (role authenticated) | ALL per-user data reads/writes — never hand-write WHERE owner_id |
serviceClient | service_role key in the Authorization header | BYPASSED | System ops only: anonymous sign-in, profile-row init, public-table upsert, cleanup |
anonClient | anon key only, no user token | ENFORCED (role anon) | Token-less GoTrue ops (e.g. refresh); can read TO anon public tables |
Integration Checklist
1. Schema + policies
profiles.idisuuid primary key references auth.users(id) on delete cascade(reuses the auth id)- Business tables carry
owner_id uuid not null references profiles(id) on delete cascade - Child tables derive ownership through the parent (no redundant
owner_id) alter table ... enable row level security;on every user-data table- Every policy:
(select auth.uid())wrapped ·TO authenticated· child tables useid in (select ...) - Every writable policy has both
usingandwith check - Every column a policy touches (
owner_id, parentid) is indexed - No policy references
user_metadata(client-writable); authz uses onlyauth.uid()/app_metadata - Policies written idempotently (
drop policy if existsthencreate);npm run db:pushapplies cleanly
2. Clients + routes
- Per-user reads/writes use
userClient(env, accessToken)— neverserviceClient serviceClientused ONLY for system ops (anon sign-in, profile init, public-table upsert, cleanup)owner_idis injected fromauthUser.userId, never read from the request bodyservice_role-backed endpoints that return lists manually strip other users' identifying fields (RLS does not apply underservice_role)
3. Public read-only tables
- Public table has a
for select to anon, authenticated using (true)policy - No
insert/update/deletepolicy foranon/authenticated - All writes go through a
service_roleendpoint that validates field-by-field - Removals are soft (
active = false), not hard deletes
4. Isolation test (must pass before shipping)
- Two different users' tokens cannot SELECT each other's rows (empty, not the data)
- A cross-owner UPDATE/DELETE affects 0 rows
- A cross-owner INSERT (forged
owner_id) returns 403 - Test is run against PostgREST directly, not only through your backend
Known Pitfalls
1. RLS enabled but no policy → everything is denied (or the reverse: a policy but RLS off)
Symptom: after enable row level security, every query returns empty even for the owner. Or: policies exist but data still leaks.
Cause: enable row level security with no policy denies all access to non-owning roles (default-deny). Conversely, writing policies but forgetting enable row level security means the policies are dormant and the table is wide open.
Fix: always pair them — enable row level security and at least one policy per verb you allow. Verify with select relrowsecurity from pg_class where relname = 'items'; (must be t).
2. using (true) on a user-data table → full leak
Symptom: every user sees every row.
Cause: a permissive using ( true ) (often copied from a public-table policy, or left in as a "temporary" open policy) applied to a user-owned table.
Fix: using ( true ) belongs only on genuinely public read-only tables. User-data policies must always predicate on (select auth.uid()). Grep your migrations for using ( true ) / using (true) and confirm each is on a public table.
3. Policy with no TO role → applies to anon too
Symptom: a logged-out client (anon key only) can read rows it should not.
Cause: omitting TO authenticated, so the policy applies to all roles including anon.
Fix: add TO authenticated to every user-data policy. Only public read-only tables should list TO anon (and then for select only).
4. with check omitted on a writable policy → owner reassignment
Symptom: a user can UPDATE their own row and change its owner_id to another user's, "giving away" or planting a row.
Cause: an UPDATE (or for all) policy with only using and no with check. using gates which rows are targeted; only with check validates the new values.
Fix: every writable policy needs with check ( (select auth.uid()) = owner_id ) in addition to using.
5. service_role used for per-user reads → RLS silently bypassed
Symptom: a list endpoint returns everyone's rows; no error, no leak warning.
Cause: the route used serviceClient (which bypasses RLS) for a per-user query, so the isolation you thought RLS gave you never ran.
Fix: per-user data always goes through userClient. Reserve serviceClient for system ops. Any endpoint that legitimately uses serviceClient and returns data to clients (feeds built server-side) must manually filter/strip other users' fields — RLS will not save you there.
6. Bare auth.uid() (not wrapped) → per-row re-evaluation
Symptom: queries on a large table are slow; EXPLAIN shows the auth function called per row.
Cause: using ( auth.uid() = owner_id ) re-evaluates auth.uid() for every scanned row.
Fix: wrap it — using ( (select auth.uid()) = owner_id ) — so the planner caches it as an init-plan (computed once).
7. Authorizing on user_metadata → self-granted privileges
Symptom: a user elevates their own access (e.g. flips themselves to "premium" or "staff").
Cause: a policy reads auth.jwt() -> 'user_metadata', which the user can write via the client SDK.
Fix: authorize only on auth.uid() and app_metadata (server-set, admin-only). Treat user_metadata as untrusted, client-supplied data.
8. Opening an authenticated UPDATE on a partially-writable public table → authoritative fields tampered
Symptom: a public directory's authoritative fields (name/address) get overwritten by clients.
Cause: to let users crowd-fill one field, an authenticated UPDATE policy was added — but RLS is row-level and cannot restrict which columns an UPDATE touches, so a client PATCHing PostgREST directly can rewrite any column.
Fix: give public tables no user-facing write policy. Route the one writable field through a service_role backend endpoint that writes exactly that column (and only under the right precondition). See implementation §3.
9. Child-table policy uses a correlated join instead of a forward set
Symptom: queries on grandchild tables are slow under load.
Cause: a policy written as exists (select 1 from items where items.id = item_events.item_id and ...) forces a correlated subquery per row.
Fix: use the forward-set membership test item_id in (select id from items where owner_id = (select auth.uid())) and index the join columns.
More in Backend
- AWS Infrastructure with CDKFreeProduction-ready AWS infrastructure using CDK — App Runner for HTTP services, Aurora Serverless for database, Cognito for auth, Lambda for serverless functions, and S3 for storage
- Supabase Storage Private Buckets + Signed URLsFreeProduction-ready private file storage for a Supabase + Cloudflare Workers stack — a private bucket created as an idempotent SQL migration, server-side service_role uploads (the client never talks to Storage and never holds the service key), and on-demand signed URLs with short TTLs. IDOR-proof by construction — the object path is derived from the verified JWT ({userId}/{itemId}.jpg, never client-supplied) and ownership is checked through RLS before any URL is signed — with defense-in-depth RLS policies on storage.objects, orphan-object cleanup (Postgres ON DELETE CASCADE never touches Storage), and the pitfalls of public buckets and leaked signed URLs.
- Supabase + Cloudflare Workers InfrastructureFreeProduction-ready serverless infrastructure using Supabase (managed Postgres + Auth + Storage) and Cloudflare Workers (Hono backend) — the zero-VPC, zero-server counterpart to the AWS CDK stack. Covers Supabase CLI migrations, the immutable-region rule, secret management (.dev.vars / Cloudflare Secret, never git), Workers deployment via wrangler, and dual-mode JWT verification (JWKS ES256 preferred, HS256 fallback).
- TikTok Ad Attribution with RevenueCat (Dual-Channel)ProFull-depth TikTok attribution for RevenueCat apps — client SDK reports the purchase moment, your backend forwards renewals/refunds from RC webhooks to TikTok Events API. Ships the complete Workers forwarder, device-identifier relay chain (ATT → RC → webhook → Events API), ASN wiring, and a battle-tested verification SOP (iOS + Cloudflare Workers)
- TikTok Ad Attribution & TrackingProTikTok ad attribution module with ATT permission flow, standard/custom event tracking, and debug logging — bridges to TikTok Business SDK via closure injection. Includes complete integration guide with credentials setup, App Store compliance, and campaign launch workflow (iOS only)