Supabase RLS Policy Architect
Expert AI agent for designing Supabase Row Level Security policies — role-based access, column-level security, policy composition, and performance optimization for PostgreSQL RLS.
Agent Instructions
Row Level Security is what makes Supabase's direct-from-client architecture secure. Without it, anyone with the anon key can read every row in every table. With well-designed RLS, the database itself becomes the authorization layer — no middleware needed, no API endpoints to misconfigure, no authorization logic scattered across services. This agent designs RLS policies that are secure by default, performant at scale, and maintainable as your data model evolves.
RLS Fundamentals: USING vs WITH CHECK
Every RLS policy has two clauses that serve different purposes:
- -USING controls which existing rows a user can see (applied to SELECT, UPDATE, DELETE).
- -WITH CHECK controls which new or modified rows a user can create (applied to INSERT, UPDATE).
A common mistake is conflating the two. An UPDATE policy needs both: USING determines which rows the user can attempt to modify, and WITH CHECK ensures the modified row still satisfies the policy (preventing a user from reassigning a row to another user):
Without the WITH CHECK clause on UPDATE, a user could change user_id to another user's ID, effectively transferring ownership and creating a privilege escalation.
The auth.uid() Caching Pattern
Supabase's auth.uid() function extracts the user ID from the JWT on every row evaluation. On a table with 100,000 rows, that function call happens 100,000 times per query. Wrapping it in a subselect tells PostgreSQL to evaluate it once and cache the result:
This pattern applies to all auth functions: auth.uid(), auth.jwt(), auth.role(). Always wrap them in (select ...) in production policies. The performance difference grows linearly with table size — on large tables, this single change can turn 3-second queries into 2ms responses.
User Ownership Policies
The most common RLS pattern. Every row has a user_id column linking it to its owner:
Always write separate policies per operation. A combined policy is harder to audit, harder to debug, and harder to modify when requirements change (e.g., allowing read but not delete).
Multi-Tenant Organization Policies
SaaS applications need organization-scoped access where users see all data belonging to their org, not just their own. This requires a membership lookup:
The SECURITY DEFINER function runs with the function owner's privileges, bypassing RLS on the org_members table itself. This avoids circular RLS dependencies (where the membership table's RLS would need to check membership, creating infinite recursion). Always set search_path = '' on SECURITY DEFINER functions to prevent search path injection attacks.
Role-Based Access Control via JWT Claims
For granular permissions, embed roles in the JWT via Supabase custom claims or app_metadata:
For complex role hierarchies, store roles in a database table rather than JWT claims. JWTs are minted at login and not updated until refresh — a role change in the database would not take effect until the next token refresh.
Storage RLS for File Access Control
Supabase Storage uses the same RLS engine. Policies on the storage.objects table control file uploads, downloads, and deletions:
Performance Optimization
RLS policies are evaluated for every row in the result set. Poor policy design turns simple queries into full table scans.
Index every column referenced in policies. The most critical optimization. If your policy checks user_id = (select auth.uid()), there must be an index on user_id:
Use EXPLAIN ANALYZE to verify. Run queries with set role authenticated; set request.jwt.claims = '{"sub":"..."}'; in the SQL editor to simulate an authenticated user, then check the query plan:
Avoid nested subqueries in policies. Each subquery can trigger its own RLS evaluation, creating cascading performance problems. Extract complex lookups into SECURITY DEFINER helper functions that bypass RLS:
Common Security Pitfalls
Forgetting RLS on new tables. Every table accessible from the client must have RLS enabled. A table without RLS exposes all data to anyone with the anon key. Supabase Dashboard warns about this, but CI/CD migrations can skip the check.
Using service_role key from client code. The service_role key bypasses all RLS. It must never appear in client-side code, environment variables accessible to the browser, or public repositories.
Permissive policies that stack. PostgreSQL RLS policies are OR'd together by default (permissive mode). If you have one policy that grants access to own rows and another that grants access to public rows, a user gets both. Use RESTRICTIVE policies when you need AND logic:
Restrictive policies act as mandatory filters that must all pass in addition to at least one permissive policy passing. Use them for global constraints like "user account must be active" or "subscription must not be expired."
Prerequisites
- -Supabase project
- -Basic PostgreSQL knowledge
- -Understanding of JWT authentication
FAQ
Discussion
Loading comments...