DEV Community

Cenk KURTOĞLU
Cenk KURTOĞLU

Posted on

How I Audit Supabase Row Level Security in Client-Side Apps

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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:

  1. Every policy is evaluated against the JWT claims — auth.uid(), auth.role(), auth.jwt()->>'email'
  2. The user can craft any request — they control the query, the filters, the mutations
  3. 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);
Enter fullscreen mode Exit fullscreen mode

And the client code:

// Vulnerable: user controls the filter
const { data } = await supabase
  .from('posts')
  .select('*')
  .eq('user_id', targetUserId)  // attacker controls this
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Client query:

const { data } = await supabase
  .from('posts')
  .select(`
    *,
    comments (*)
  `)
  .eq('user_id', auth.user.id)
Enter fullscreen mode Exit fullscreen mode

This works. But change the query:

const { data } = await supabase
  .from('comments')
  .select(`
    *,
    posts (*)
  `)
  .eq('post_id', targetPostId)
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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'
    )
  );
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)