Supabase + RLS

Pathrule3 Rules • 3 Memories • 1 Skill

Rules, memories, and a review skill for Supabase Postgres projects that take Row Level Security seriously. Pre-scoped to your supabase directory so your AI assistant enforces RLS, keeps the service role out of clients, and follows a disciplined migration flow.

Suggested path map

Pathrule places each piece on the matching path, so your assistant only sees it where it belongs. This is the scoping you get on import; you can adjust it in your workspace.

/ workspace root
supabase-rls-review
supabase/
RLS enabled on every table, deny by default
Service role key stays server-side; never in client paths
Centralize access checks in a SECURITY DEFINER helper
auth.uid(), auth.jwt(), and custom claims in policies
migrations/
Index every column used in RLS policy predicates
Supabase migration workflow

Rules

3
RLS enabled on every table, deny by default/supabasehighstrictNo table ships without Row Level Security enabled and explicit per-operation policies.
1Every user-facing table in the public schema must have Row Level Security enabled with a deny-by-default posture. A table with RLS disabled is readable and writable by any authenticated user.
2 
3- Enable RLS in the same migration that creates the table: `ALTER TABLE <name> ENABLE ROW LEVEL SECURITY`. Never rely on the application layer to enforce access at the row level.
4- Start from no policies (which denies everything) and add the minimum set of policies per operation (`SELECT`, `INSERT`, `UPDATE`, `DELETE`) that the feature actually requires. A catch-all `USING (true)` policy is equivalent to no RLS.
5- A migration that creates a table without enabling RLS and adding policies is incomplete and must not merge to a shared environment.
6- Internal or log tables used only by server-side jobs that run as the service role may skip per-user policies, but must still have RLS enabled and an explicit `USING` expression that allows only the service role, not a blanket bypass.
Service role key stays server-side; never in client paths/supabasehighstrictThe service role key bypasses RLS entirely and must never appear in browser code, client environment variables, or per-request edge function logic acting on behalf of a user.
1The Supabase service role key grants full unrestricted access to every row in every table, bypassing all RLS policies. Leaking it to a browser or a client-accessible path renders all RLS useless.
2 
3- Never include the service role key in any environment variable exposed to the browser (for example `NEXT_PUBLIC_*` in Next.js, or any variable bundled by Vite/webpack for the client).
4- Edge functions that handle per-user requests must create their Supabase client with the user's JWT from the `Authorization` header, not the service role: `createClient(url, anonKey, { global: { headers: { Authorization: req.headers.get('Authorization') } } })`. This ensures RLS evaluates under the correct user identity.
5- Use the service role key only in trusted server-side contexts (scheduled functions, admin scripts, server-to-server calls) where bypassing RLS is the deliberate intent and the scope is understood.
6- Never derive or reconstruct the service role key from a client request, environment variable pattern, or any value accessible to end users.
Index every column used in RLS policy predicates/supabase/migrationsmediumadvisoryAn unindexed column in a USING or WITH CHECK expression causes a full-table scan on every row-level check.
1Postgres evaluates each RLS policy's `USING` and `WITH CHECK` expressions once per row that the query touches. An expression that references an unindexed column forces a sequential scan on every row access, not just on the query plan — the cost is invisible in `EXPLAIN` for the outer query but shows up as degraded performance under load.
2 
3- Add a B-tree index on every column that appears in a `USING (...)` or `WITH CHECK (...)` expression, particularly `user_id`, `workspace_id`, `tenant_id`, and any other access-boundary column.
4- For policies that call `auth.uid()`, the `user_id` column they compare against must be indexed; the `auth.uid()` itself is a function call that Postgres inlines as a constant per statement, so it is not the bottleneck, but the column scan is.
5- If using a `SECURITY DEFINER` helper function (see the access-helper memory), index the columns inside that function's WHERE clause, not just the surface-level expression in the policy.
6- Run `EXPLAIN ANALYZE` on representative queries before and after adding RLS to confirm the policy predicates are not adding full-table sequential scans.

Memories

3
Centralize access checks in a SECURITY DEFINER helper/supabaseOne SQL function that expresses membership or ownership, reused by all policies across tables.
1Writing the same `EXISTS (SELECT 1 FROM memberships WHERE ...)` check inline in multiple policies creates drift: tables diverge, a bug fix in one policy misses others, and refactoring access logic means touching every policy.
2 
3- Express membership and ownership in one `SECURITY DEFINER` function, for example:
4 ```sql
5 CREATE OR REPLACE FUNCTION has_workspace_access(p_workspace_id uuid)
6 RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $$
7 SELECT EXISTS (
8 SELECT 1 FROM workspace_members
9 WHERE workspace_id = p_workspace_id
10 AND user_id = auth.uid()
11 );
12 $$;
13 ```
14- Reference the helper in all relevant policies: `CREATE POLICY "workspace read" ON documents FOR SELECT USING (has_workspace_access(workspace_id))`.
15- `SECURITY DEFINER` runs the function as the function owner (typically `postgres`), which avoids RLS recursion if `workspace_members` itself is RLS-protected. Mark it `STABLE` so the planner can cache the result per statement.
16- Grant `EXECUTE` only to `authenticated` and `service_role`; do not grant to `anon` unless anonymous access is intentional.
auth.uid(), auth.jwt(), and custom claims in policies/supabaseHow to use Supabase JWT claims in RLS expressions and how to attach custom claims via a hook without performance footguns.
1Supabase injects the authenticated user's identity into the Postgres session so RLS policies can reference it without a subquery to `auth.users`.
2 
3- `auth.uid()` returns the `sub` claim of the JWT as a `uuid`. Use it directly in policy `USING` expressions: `USING (user_id = auth.uid())`.
4- `auth.jwt()` returns the full decoded JWT as `jsonb`. Access top-level claims with `->>`: `auth.jwt() ->> 'role'`. For nested claims (for example `app_metadata`): `(auth.jwt() -> 'app_metadata') ->> 'plan'`.
5- Custom claims (roles, plan tier, org ID) are the recommended way to encode authorization context without an extra DB lookup per row. Add them via a Supabase Auth Hook (the `custom_access_token` hook in the Dashboard), which runs a Postgres function that appends claims to every JWT before it is issued.
6- Never call `SELECT ... FROM auth.users` inside a policy `USING` expression just to read a claim that is already in the JWT. It issues a subquery per row. Use `auth.jwt()` to read the claim from the already-present token instead.
7- When a custom claim must be refreshed (for example after a plan upgrade), call `supabase.auth.refreshSession()` on the client to force a new token with updated claims; old tokens keep the stale claims until they expire.
Supabase migration workflow/supabase/migrationsForward-only timestamped SQL, RLS and policies in the same migration as the table, TypeScript types regenerated after each change.
1Supabase migrations are plain SQL files managed by the Supabase CLI. The workflow keeps the database and generated types in sync.
2 
3- Generate a new migration with `supabase migration new <description>`, which creates a timestamped file in `supabase/migrations/`. Write forward-only SQL; there are no down steps.
4- Enable RLS and add all required policies in the same migration file that creates the table. Never leave the table in a state where it exists but RLS is not yet enabled, even briefly across separate migrations.
5- After applying a migration that changes the schema, regenerate TypeScript types: `supabase gen types typescript --linked > src/types/supabase.ts` (or equivalent). Commit the updated types file in the same PR as the migration.
6- Apply locally with `supabase db reset` (for local dev) or `supabase db push` (for remote preview branches). Never edit a migration file that has already been applied to any shared environment; add a new migration instead.
7- For Supabase hosted projects, use branching (`supabase branches`) to develop and test schema changes on an isolated preview branch before merging to production.

Skills

1
supabase-rls-review/rootChecklist for reviewing a Supabase schema or migration change for RLS correctness, performance, and workflow safety.
1---
2name: supabase-rls-review
3description: Review a Supabase migration or schema change for RLS correctness, JWT claim usage, access-helper design, policy-predicate indexing, and migration workflow safety.
4---
5 
6# Supabase RLS review
7 
8## RLS and policies
9- [ ] RLS is enabled on every new table in the same migration that creates it.
10- [ ] Policies exist per operation (`SELECT`, `INSERT`, `UPDATE`, `DELETE`) and the default is deny (no `USING (true)` catch-all).
11- [ ] Policies use the shared `SECURITY DEFINER` access helper, not ad-hoc inline subqueries that would differ across tables.
12- [ ] Internal/job tables accessed only via the service role have explicit `USING` expressions limiting to the service role, not a blanket bypass.
13 
14## Performance
15- [ ] Every column referenced in a `USING` or `WITH CHECK` expression (especially `user_id`, `workspace_id`, `tenant_id`) has a B-tree index.
16- [ ] No policy calls `SELECT ... FROM auth.users` per row to read a claim that is already available via `auth.jwt()`.
17 
18## JWT and custom claims
19- [ ] Policies use `auth.uid()` for user identity and `auth.jwt()` for custom claims; no unnecessary subqueries to `auth.users`.
20- [ ] Custom claims added via the Auth Hook are documented and the client calls `refreshSession()` after claim-changing events (plan upgrade, role change).
21 
22## Service role
23- [ ] No service role key appears in any browser-accessible environment variable or in edge function code acting on behalf of a user.
24- [ ] Edge functions acting on behalf of a user forward the `Authorization: Bearer <user_jwt>` header to the Supabase client, not the service role key.
25 
26## Migration workflow
27- [ ] TypeScript types are regenerated and committed alongside the migration.
28- [ ] Migration is forward-only and no already-applied file was edited; a new file was added.

Why this pattern

Postgres tables ship without Row Level Security, or the service role key sneaks into client paths, leaving data open to anyone.

Built for teams building on Supabase Postgres with multi-tenant or per-user data.

Keeps your assistant from:

  • Creating a table without RLS enabled and policies
  • Using the service role key where the user's JWT belongs
  • Ad hoc access checks that drift apart across tables
License
Apache-2.0
Version
1.0.0
Updated
2026-06-09
View source