Supabase Row Level Security: Beginner Guide 2026
Master RLS policies in Supabase with production-ready patterns, real error messages, and step-by-step implementation for indie hackers.
TL;DR
Supabase Row Level Security (RLS) lets you enforce database-level access control automatically. Enable RLS on tables, create policies matching your auth logic, and test thoroughly. Common mistakes: forgetting auth.uid(), mixing up USING vs WITH CHECK, and not testing unauthenticated access.
---
What is Row Level Security?
Row Level Security is a PostgreSQL feature that restricts database query results based on policies you define. With Supabase, you get PostgreSQL + built-in auth, making RLS the best place to enforce data access rules.
Why not app-level checks? Because users can bypass your frontend. RLS lives at the database—impossible to circumvent.
Current stability: RLS in Supabase (as of 2026) is production-ready. [Official Supabase docs](https://supabase.com/docs/guides/auth/row-level-security) cover the latest implementation.
---
Step 1: Enable RLS on Your Table
First, enable RLS. Without this, policies don't apply:
```sql ALTER TABLE profiles ENABLE ROW LEVEL SECURITY; ```
Critical: Once enabled, *all* queries are blocked by default unless a policy explicitly allows them. Test immediately.
---
Step 2: Create Your First Policy
Let's say users should only read their own profile:
```sql CREATE POLICY "Users can read own profile" ON profiles FOR SELECT USING (auth.uid() = user_id); ```
Breakdown:
FOR SELECT — applies to read queries onlyUSING — the condition that must be true for a row to appearauth.uid() — the authenticated user's UUID from Supabase Authuser_id — your table's foreign key to auth.users---
Policy Types: USING vs WITH CHECK
Two clauses control different operations:
USING — applies to SELECT and DELETE
```sql
CREATE POLICY "Users can delete own posts"
ON posts
FOR DELETE
USING (auth.uid() = author_id);
```
WITH CHECK — applies to INSERT and UPDATE
```sql
CREATE POLICY "Users can update own posts"
ON posts
FOR UPDATE
USING (auth.uid() = author_id) -- can they access it?
WITH CHECK (auth.uid() = author_id); -- can they write to it?
```
Both required? On UPDATE, yes. USING filters which rows they can access; WITH CHECK ensures they can't escalate privileges by writing to author_id.
---
Real Error Messages You'll See
Error 1: "new row violates row-level security policy"
``` PostgresError: new row violates row level security policy "Users can update own posts" for table "posts" ``` Cause: YourWITH CHECK clause failed. Usually means you're trying to update a row you don't own, or change author_id to someone else's.Fix: Verify your policy allows the update, or check you're passing the correct user_id.
Error 2: "permission denied for schema public"
``` PostgresError: permission denied for schema "public" ``` Cause: RLS is enabled, you're unauthenticated, and no policy allows public access.Fix: Either create a policy for unauthenticated users, or ensure your client sends a valid JWT token.
Error 3: "relation does not exist"
``` PostgresError: relation "auth.users" does not exist ``` Cause: Typo in table reference, or assuming auth schema is accessible (it's not in some Supabase setups).Fix: Use auth.uid() directly; don't query auth.users in policies.
---
Production-Ready Pattern: Role-Based Access
Most apps need different permissions per role. Here's the pattern:
```sql -- Add role to profiles ALTER TABLE profiles ADD COLUMN role TEXT DEFAULT 'user';
-- Admins can see all profiles CREATE POLICY "Admins read all profiles" ON profiles FOR SELECT USING ( (SELECT role FROM profiles WHERE id = auth.uid()) = 'admin' );
-- Users see only themselves CREATE POLICY "Users read own profile" ON profiles FOR SELECT USING (auth.uid() = id); ```
Warning: Querying the same table in USING can cause recursion. For complex roles, use a separate roles table:
```sql CREATE TABLE roles ( user_id UUID PRIMARY KEY REFERENCES auth.users(id), role TEXT NOT NULL );
CREATE POLICY "Admins read all profiles" ON profiles FOR SELECT USING ( (SELECT role FROM roles WHERE user_id = auth.uid()) = 'admin' ); ```
---
Testing RLS Policies
Always test both authenticated and unauthenticated scenarios:
```sql -- Test as authenticated user SET request.jwt.claims = '{"sub": "550e8400-e29b-41d4-a716-446655440000"}'; SELECT * FROM profiles; -- Should show only that user's profile
-- Test as unauthenticated RESET request.jwt.claims; SELECT * FROM profiles; -- Should return 0 rows (unless you allow it) ```
In the Supabase dashboard, use the SQL editor with Impersonate User button to test as real users.
---
Common Mistakes
1. Forgetting to enable RLS first
Policies exist but don't restrict anything. Always run ALTER TABLE ... ENABLE ROW LEVEL SECURITY;
2. Using auth.users in policies
You can't access auth.users directly in RLS. Use auth.uid(), auth.jwt(), or custom claims.
3. Mixing up column names
If your table uses user_uuid but Supabase sends auth.uid() (UUID), they must match exactly.
4. Not testing unauthenticated access Once RLS is on, unauthenticated users see nothing unless you explicitly allow it. Test this.
---
Debugging RLS Issues
Enable query logging:
```sql ALTER SYSTEM SET log_statement = 'all'; SELECT pg_reload_conf(); ```
Then check Postgres logs in Supabase dashboard (Settings → Logs).
For client-side debugging, log the JWT: ```javascript const { data: { session } } = await supabase.auth.getSession(); console.log(session?.access_token); // Decode on jwt.io to inspect claims ```
---
Next Steps
Once you master basic RLS, explore:
For deeper PostgreSQL RLS details, see [official PostgreSQL docs](https://www.postgresql.org/docs/current/ddl-rowsecurity.html).
---
What am I missing?
RLS can get complex fast. Have you hit edge cases with:
Drop your questions and gotchas in the comments. Corrections welcome—accuracy matters here.