I've spent the last few months reviewing Supabase projects submitted through GitHub issues. The pattern is always the same: developers enable RLS but write policies that look correct in the dashboard while leaking data in production.
Here's the audit process I run on every client-side Supabase app.
Start With the Schema, Not the Policies
Most audits begin in the SQL editor. I run this first:
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;
This tells me which tables have RLS enabled. But the real signal is what's missing: tables without rowsecurity = true that store user data. I've seen profiles, subscriptions, and audit_logs tables completely unprotected because the team assumed RLS was "on by default."
Next, I check for FOR ALL policies:
SELECT schemaname, tablename, policyname, permissive, roles, cmd, qual, with_check
FROM pg_policies
WHERE schemaname = 'public'
ORDER BY tablename, policyname;
A FOR ALL policy using auth.uid() = user_id looks fine until you realize it applies to SELECT, INSERT, UPDATE, and DELETE simultaneously. That's rarely what you want.
The Client-Side Attack Surface
In a client-side app (Next.js, React, Vue), the Supabase client runs in the browser with the anon key. This means:
-
Every policy is evaluated against the JWT claims —
auth.uid(),auth.role(),auth.jwt()->>'email' - The user can craft any request — they control the query, the filters, the mutations
- No server-side validation exists unless you build it
The most common bypass I see: a policy like auth.uid() = user_id on a SELECT policy, but the client queries select * from posts where user_id = 'some-other-id'. The policy evaluates to false, so zero rows return. That's correct behavior.
But then I see this pattern:
-- Policy on "posts" table
CREATE POLICY "Users can view own posts" ON posts
FOR SELECT USING (auth.uid() = user_id);
And the client code:
// Vulnerable: user controls the filter
const { data } = await supabase
.from('posts')
.select('*')
.eq('user_id', targetUserId) // attacker controls this
If targetUserId isn't the current user, the query returns empty. But what if the policy uses WITH CHECK instead of USING? Or what if there's a join involved?
The Join Problem
This is where most audits find real issues. Consider:
-- posts table
CREATE POLICY "Own posts only" ON posts
FOR SELECT USING (auth.uid() = user_id);
-- comments table
CREATE POLICY "Own comments only" ON comments
FOR SELECT USING (auth.uid() = user_id);
Client query:
const { data } = await supabase
.from('posts')
.select(`
*,
comments (*)
`)
.eq('user_id', auth.user.id)
This works. But change the query:
const { data } = await supabase
.from('comments')
.select(`
*,
posts (*)
`)
.eq('post_id', targetPostId)
Now the attacker reads comments on any post, and through the join, the post data itself — bypassing the posts table policy entirely. The comment policy only checks auth.uid() = user_id on the comments table. The joined posts table isn't re-evaluated.
Fix: Every table in a join chain needs its own policy that accounts for the access path. Or use security definer functions to encapsulate the logic.
The auth.jwt() Trap
Supabase exposes the full JWT via auth.jwt(). I see policies like:
CREATE POLICY "Admin access" ON sensitive_data
FOR ALL USING (auth.jwt()->>'role' = 'admin');
The problem: the JWT is client-controllable in some edge cases (token refresh race conditions, custom access tokens). More importantly, auth.jwt() returns the claims, not a verified role. The proper check is:
CREATE POLICY "Admin access" ON sensitive_data
FOR ALL USING (
EXISTS (
SELECT 1 FROM user_roles
WHERE user_id = auth.uid() AND role = 'admin'
)
);
This hits a source-of-truth table, not a token claim.
Testing Policies Like an Attacker
I don't trust the policy simulator in the dashboard. I write a test script that uses the anon key directly:
// test-rls.js
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_ANON_KEY
)
async function testAsUser(userId, userToken) {
const client = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_ANON_KEY,
{ global: { headers: { Authorization: `Bearer ${userToken}` } } }
)
// Try to read another user's data
const { data, error } = await client
.from('profiles')
.select('*')
.eq('id', 'other-user-id')
console.log(`User ${userId}:`, data, error)
}
Run this with two different user tokens. If both can read each other's data, the policy is broken.
The Checklist I Use
Every audit gets these checks:
| Check | Query / Test |
|---|---|
| RLS enabled on all user tables | pg_tables.rowsecurity |
No FOR ALL policies |
pg_policies.cmd = '*' |
No policies using only auth.jwt()
|
grep for auth.jwt() in policies |
| Join paths covered | Manual query testing with cross-table selects |
| Insert policies validate ownership |
WITH CHECK matches USING
|
| Update policies prevent privilege escalation |
WITH CHECK includes auth.uid() = user_id
|
| Delete policies exist |
FOR DELETE policies on all mutable tables |
| No public read on sensitive tables |
SELECT policies require auth |
One Pattern That Saves Time
For new projects, I now recommend this baseline:
-- Enable RLS
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
-- Single policy per operation
CREATE POLICY "Profiles select own" ON profiles
FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Profiles insert own" ON profiles
FOR INSERT WITH CHECK (auth.uid() = id);
CREATE POLICY "Profiles update own" ON profiles
FOR UPDATE USING (auth.uid() = id)
WITH CHECK (auth.uid() = id);
CREATE POLICY "Profiles delete own" ON profiles
FOR DELETE USING (auth.uid() = id);
Explicit per-operation policies are verbose but auditable. FOR ALL hides bugs.
The Gap Nobody Talks About
RLS policies run after the query planner. A malicious query that causes a full table scan before policy evaluation can still leak timing information or cause DoS. I've seen SELECT * FROM large_table WHERE user_id = $1 where user_id isn't indexed — the policy passes but the query scans millions of rows.
Add indexes on every column used in USING/WITH CHECK clauses. It's not a security fix, but it prevents the operational issue that makes teams disable RLS "temporarily."
Top comments (0)