skills/postgres-row-level-security/SKILL.md
Designing Postgres Row-Level Security (RLS) policies for multi-tenant authorization, especially in Supabase / PostgREST stacks — `CREATE POLICY` syntax, USING vs WITH CHECK, PERMISSIVE/RESTRICTIVE merge semantics, the `(SELECT auth.uid())` performance pattern that turns 171ms scans into <1ms, indexes still required, role-based bypass via BYPASSRLS, security-definer escape hatches. Grounded in postgresql.org, Supabase docs, and Gary Austin's RLS-Performance benchmarks.
npx skillsauth add curiositech/windags-skills postgres-row-level-securityInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
TL;DR:
ENABLE ROW LEVEL SECURITYis default-deny.CREATE POLICYadds USING (read filter) and/or WITH CHECK (write filter). Multiple PERMISSIVE policies OR together; multiple RESTRICTIVE policies AND. The single biggest performance fix: wrapauth.uid()in(SELECT auth.uid())to cache the result once per statement (94-99% latency reduction). RLS adds a WHERE clause but does not add indexes — youruser_idcolumn still needs one.
| Symptom | Section | |---|---| | "Need 'user can only see their own rows'" | Canonical patterns | | "RLS query went from 1ms to 1700ms" | Performance | | "Two policies — what's the merge?" | PERMISSIVE vs RESTRICTIVE | | "Service role / admin needs to bypass" | Bypass paths | | "Views aren't enforcing RLS" | Views gotcha | | "When is USING vs WITH CHECK used?" | USING vs WITH CHECK |
flowchart TD
A[Multi-tenant table needs authorization] --> B[ALTER TABLE t ENABLE ROW LEVEL SECURITY]
B --> C{Owner should also be subject to RLS?<br/>e.g. service_role wired through PostgREST}
C -->|Yes| D[ALTER TABLE t FORCE ROW LEVEL SECURITY]
C -->|No| E[Skip FORCE - owner bypasses]
D --> F[Decide policy types per command]
E --> F
F --> G{SELECT visibility?}
G -->|"User sees own rows"| H[CREATE POLICY ... FOR SELECT TO authenticated<br/>USING (auth.uid)= user_id)]
F --> I{INSERT validation?}
I -->|"User can only create own row"| J[CREATE POLICY ... FOR INSERT TO authenticated<br/>WITH CHECK (auth.uid)= user_id)]
F --> K{UPDATE - both filter AND validate?}
K -->|Yes| L[USING + WITH CHECK both<br/>typically same predicate]
H --> M[Performance audit]
J --> M
L --> M
M --> N{Hot read path?}
N -->|Yes| O[Wrap auth.uid in SELECT subquery<br/>+ index user_id<br/>+ specify TO <role>]
N -->|No| P[Done]
O --> P
CREATE POLICY syntaxFrom postgresql.org/docs/current/sql-createpolicy.html:
CREATE POLICY name ON table_name
[ AS { PERMISSIVE | RESTRICTIVE } ]
[ FOR { ALL | SELECT | INSERT | UPDATE | DELETE } ]
[ TO { role_name | PUBLIC | CURRENT_ROLE | CURRENT_USER | SESSION_USER } [, ...] ]
[ USING ( using_expression ) ]
[ WITH CHECK ( check_expression ) ]
| Command | USING (filter visible/affected rows) | WITH CHECK (validate new row values) |
|---|---|---|
| SELECT | required | not allowed |
| INSERT | not allowed | required |
| UPDATE | yes (which existing rows updatable) | yes (resulting row valid) |
| DELETE | required | not allowed |
| ALL | yes | yes (defaults to USING if omitted) |
The mental model: USING is "can the user see / affect this row?" WITH CHECK is "is the user allowed to write this exact value?"
For UPDATE, both apply: USING gates which rows the user can attempt to update; WITH CHECK gates whether the resulting row is allowed (so a user can't UPDATE their row to assign it to someone else).
If you omit WITH CHECK on an UPDATE/ALL policy, it defaults to the USING expression — usually what you want.
ALTER TABLE t ENABLE ROW LEVEL SECURITY;
Once enabled, no rows are visible to any non-bypass role until policies are added. This is the correct safe default — fail closed, not open.
ALTER TABLE t FORCE ROW LEVEL SECURITY; -- owner is also subject to policies
Without FORCE, the table owner bypasses RLS. With FORCE, even the owner must satisfy policies. Use FORCE when the connection role (e.g., the Postgres user PostgREST connects as) might own tables — otherwise you have a bypass-by-accident.
For a given command, the effective predicate is:
( PERMISSIVE_1 OR PERMISSIVE_2 OR ... )
AND
( RESTRICTIVE_1 AND RESTRICTIVE_2 AND ... )
PERMISSIVE.Worked example: layer a network restriction on top of normal access:
-- Permissive: standard access
CREATE POLICY "users see own rows" ON profiles
FOR SELECT TO authenticated
USING ((SELECT auth.uid()) = user_id);
-- Restrictive: but admins must come from the office network
CREATE POLICY "admin local only" ON profiles
AS RESTRICTIVE FOR SELECT TO admin
USING (pg_catalog.inet_client_addr() <<= inet '10.0.0.0/8');
A regular user sees their own row (permissive matches). An admin sees rows only when also on 10.0.0.0/8 (permissive AND restrictive). A user not on the office network as admin sees nothing.
Do not rely on RESTRICTIVE alone — without at least one matching permissive, all rows are filtered.
Supabase wraps PostgREST + RLS with helper functions that read JWT claims. From supabase.com/docs/guides/database/postgres/row-level-security:
| Function | Returns |
|---|---|
| auth.uid() | UUID of authenticated user; NULL if unauthenticated |
| auth.jwt() | Full JWT as JSON; access claims via ->/->> |
| auth.role() | Role string (anon, authenticated, etc.) |
The NULL gotcha: USING (auth.uid() = user_id) silently fails for anon callers (NULL = anything → NULL → row not visible — actually correct, but the failure mode is silent). Recommended:
USING (auth.uid() IS NOT NULL AND auth.uid() = user_id)
CREATE POLICY "Own rows visible" ON todos
FOR SELECT TO authenticated
USING ((SELECT auth.uid()) = user_id);
CREATE POLICY "Users can create own profile" ON profiles
FOR INSERT TO authenticated
WITH CHECK ((SELECT auth.uid()) = user_id);
CREATE POLICY "Update own profile" ON profiles
FOR UPDATE TO authenticated
USING ((SELECT auth.uid()) = user_id) -- which rows can I touch
WITH CHECK ((SELECT auth.uid()) = user_id); -- new value must still be mine
CREATE POLICY "User is in team" ON team_documents
TO authenticated
USING (team_id IN (SELECT auth.jwt() -> 'app_metadata' -> 'teams'));
Supabase explicitly warns: prefer
raw_app_meta_dataoverraw_user_meta_datain policies — users can self-edituser_metadata.
CREATE POLICY "Require AAL2" ON profiles
AS RESTRICTIVE FOR UPDATE TO authenticated
USING ((SELECT auth.jwt()->>'aal') = 'aal2');
Behind Supabase / any PostgREST stack:
SET LOCAL ROLE per request based on the JWTrole claimBYPASSRLS; must never reach the browserGRANT authenticated TO authenticator;
GRANT anon TO authenticator;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.todos TO authenticated;
GRANT SELECT ON public.todos TO anon;
ALTER TABLE public.todos ENABLE ROW LEVEL SECURITY;
JWT claims are accessible via:
current_setting('request.jwt.claims', true)::json->>'email'
Supabase's auth.uid() / auth.jwt() are wrappers around exactly this current_setting('request.jwt.claims', ...) mechanism.
This is the single highest-leverage RLS performance pattern. From Gary Austin's RLS-Performance benchmark repo:
| Technique | Before → After | Improvement |
|---|---|---|
| Add btree index on user_id | 171ms → <0.1ms | 99.94% |
| Wrap auth.uid() in (SELECT …) | 179ms → 9ms | 94.97% |
| Wrap security-definer function (SELECT is_admin()) | 11_000ms → 7ms | 99.94% |
| Add explicit .eq('user_id', …) filter on client | 171ms → 9ms | 94.74% |
| Rewrite join → IN (SELECT …) | 9_000ms → 20ms | 99.78% |
| Add TO authenticated (skip anon eval) | 170ms → <0.1ms | 99.78% |
USING (auth.uid() = user_id) calls auth.uid() per row. On a 1M-row scan, that's 1M function calls.
USING ((SELECT auth.uid()) = user_id) causes Postgres to build an initPlan — a one-time computation that runs once per statement, then is reused across all row evaluations. The result: 1 call instead of 1M.
This is only safe when the function result is row-independent. auth.uid(), auth.jwt(), current_setting() qualify. If your predicate uses a function whose result depends on the row, you can't wrap it.
CREATE INDEX idx_todos_user_id ON todos (user_id);
RLS adds an implicit WHERE user_id = auth.uid() clause but does not create indexes. The policy column (typically user_id) must be indexed or every read becomes a sequential scan with a per-row filter.
TO <role>-- Bad: policy evaluated for ALL roles including anon
CREATE POLICY ... ON todos USING (...);
-- Good: skip evaluation for non-matching roles
CREATE POLICY ... ON todos TO authenticated USING (...);
When TO is omitted, the policy is evaluated against PUBLIC — every role, including ones that should never reach the table. Specifying TO authenticated lets Postgres skip the policy entirely for anon.
Even though RLS enforces it, the optimizer builds better plans when the predicate is also in the query:
// Worse: relies on RLS alone
supabase.from('todos').select('*')
// Better: explicit predicate gives the optimizer hints
supabase.from('todos').select('*').eq('user_id', user.id)
The explicit .eq() lets Postgres use the index directly; without it, the planner may decide the RLS predicate alone isn't index-worthy.
Two ways to legitimately skip RLS:
BYPASSRLS role attributeALTER ROLE service_role WITH BYPASSRLS;
The role permanently ignores all RLS policies on all tables. Use for:
Never expose a BYPASSRLS connection to user-facing code. Supabase's service_role key (in .env) gives this access — leaking it is a full data breach.
SECURITY DEFINER functionsCREATE FUNCTION get_team_total(team_id uuid) RETURNS int
LANGUAGE sql SECURITY DEFINER AS $$
SELECT count(*) FROM team_members WHERE team_id = $1
$$;
The function runs with the privileges of the function owner, not the caller. If the owner is postgres (RLS bypass), the function bypasses RLS for the duration of its execution.
This is the canonical escape hatch for narrow whitelisted operations — but it's also the most common place RLS gets accidentally bypassed. Rules:
SECURITY DEFINER functions you've reviewed line-by-lineSET search_path = '' inside to prevent search-path injectionpublic schema)| Anti-pattern | Why it bites | Fix |
|---|---|---|
| RLS policy uses auth.uid() (no subselect) | Per-row function call → 100-1000× slowdown | (SELECT auth.uid()) for caching |
| user_id column not indexed | Sequential scan + per-row filter | CREATE INDEX ON tbl (user_id) |
| Policy without TO <role> clause | Evaluates for every role including anon | Always specify TO authenticated (or whichever) |
| Views silently bypass RLS | Pre-PG15: views run with creator's privileges | PG15+: WITH (security_invoker = true). Older: revoke direct grants and rely on the view's RLS |
| service_role key leaks to browser | Full database compromise | Service role only on server-side; browser uses anon + RLS |
| SECURITY DEFINER exposed in PostgREST API schema | API call bypasses RLS | Keep these in a private schema; not in public |
| Restrictive-only policies (no permissive) | All rows denied | Add at least one permissive policy or rely on grant-based access |
| USING (true) policy | RLS enabled but no actual filtering | Either remove the policy or add real predicate |
| UPDATE without WITH CHECK | User can update their row to belong to someone else | Always pair USING + WITH CHECK on UPDATE |
| Trusting raw_user_meta_data in policies | Users can self-edit it | Use raw_app_meta_data instead |
| FK / unique constraint as covert channel | These bypass RLS by design | Audit FK reveals (the existence of a referenced row leaks); design schema accordingly |
| | Novice | Expert |
|---|---|---|
| First policy | USING (auth.uid() = user_id) no TO, no subselect | TO authenticated USING ((SELECT auth.uid()) = user_id) + index |
| UPDATE policy | USING only | USING + WITH CHECK to prevent reassignment |
| Performance debugging | "RLS is slow, disable it" | EXPLAIN ANALYZE; subselect wrap; index audit |
| Service role | Used in client code | Server-only; client never sees it |
| Views | Surprised they bypass | Knows pre-PG15 quirk; uses security_invoker = true |
| Multiple policies | Confused by interaction | Knows OR-permissive AND-restrictive merge |
Timeline: pre-PG15 — view RLS bypass was the constant gotcha. PG15+ (2022) — security_invoker = true makes views respect RLS. PG16+ — lateral joins inside policies got better-optimized. Supabase's (SELECT auth.uid()) pattern (~2023) is the single biggest performance discovery — pre-2023 docs may not show it.
An RLS configuration ships when:
ALTER TABLE … ENABLE ROW LEVEL SECURITY is set; verified by \d+ <table> showing "Row security enabled".TO <role> (no PUBLIC defaults). CI grep for CREATE POLICY without TO.auth.uid() / auth.jwt() wrap them in (SELECT …). CI grep / lint.user_id (or equivalent tenant column) has a btree index. pg_indexes query in CI.SECURITY DEFINER function in the API-exposed schema (Supabase: not in public). CI grep.service_role key is not present in any client-side bundle. Bundle scan in CI.WITH (security_invoker = true) (PG15+) or are explicitly designed for RLS bypass.authorization-design)oauth2-and-oidc-from-scratch)postgres-explain-analyzer)database-design-patterns)postgres-connection-pooling)CREATE POLICY reference — full syntax, USING/WITH CHECK applicabilityauth.uid(), auth.jwt(), canonical patterns, performance sectiondata-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
tools
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
development
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.