DEV Community

Cenk KURTOĞLU
Cenk KURTOĞLU

Posted on

Supabase RLS Mistakes That Leak User Data

Row Level Security (RLS) is one of Supabase's most powerful features—and one of the most misunderstood. The same RLS misconfigurations appear repeatedly across projects. They're not exotic vulnerabilities; they're predictable gaps that happen when developers treat RLS as a checkbox instead of a policy layer.

Here are the five mistakes seen most often, why they leak data, and how to fix them.

1. Enabling RLS Without Policies

A developer creates a table, runs ALTER TABLE ... ENABLE ROW LEVEL SECURITY, and assumes they're done. In PostgreSQL, enabling RLS without any policies means no rows are visible to anyone—including the table owner—unless they have the BYPASSRLS attribute (typically only superusers). If you then grant SELECT on the table to a role without policies, that role sees nothing. The permission exists, but the policy engine returns zero rows.

The leak happens when someone later adds a permissive policy for a service role or admin user, forgetting that the default-deny behavior also applies to authenticated users. Or worse: they disable RLS temporarily for debugging, forget to re-enable it, and ship to production.

Fix: Always pair ENABLE ROW LEVEL SECURITY with at least one policy in the same migration. Use a restrictive default policy first, then add permissive ones explicitly.

-- Good: restrictive default, then explicit grants
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users can view own profile"
  ON profiles FOR SELECT
  USING (auth.uid() = id);

-- Explicitly grant service role access if needed
CREATE POLICY "Service role full access"
  ON profiles FOR ALL
  USING (auth.role() = 'service_role');
Enter fullscreen mode Exit fullscreen mode

2. Using auth.uid() in Policies Without Checking auth.role()

auth.uid() returns the JWT subject claim. It works for authenticated users, but it also returns a value for anonymous users (the anon role) if they have a valid JWT—which they do by default in Supabase.

If your policy is:

CREATE POLICY "Users see own data"
  ON documents FOR SELECT
  USING (auth.uid() = user_id);
Enter fullscreen mode Exit fullscreen mode

An anonymous user with a valid anon token can query documents where user_id matches their anon UID. Since anon UIDs are generated per session, this can leak data across anonymous sessions if your app ever writes data tied to auth.uid() before the user signs up.

Fix: Always gate policies on auth.role() when the intent is "authenticated users only."

CREATE POLICY "Authenticated users see own data"
  ON documents FOR SELECT
  USING (
    auth.role() = 'authenticated'
    AND auth.uid() = user_id
  );
Enter fullscreen mode Exit fullscreen mode

3. Relying on Client-Side Filtering Instead of Policy Conditions

Supabase's client libraries make it easy to write .eq('user_id', user.id) in your query. Developers often treat this as a security boundary. It's not. The client constructs the query; a malicious user can modify it before it leaves the browser.

RLS policies run on the server, after the query is parsed but before execution. They're the only reliable filter.

Fix: Move every authorization check into the policy. The client should send minimal queries; the policy enforces access.

-- Bad: client sends .eq('user_id', 'some-other-id')
-- Policy doesn't check ownership
CREATE POLICY "Anyone can read"
  ON notes FOR SELECT
  USING (true);

-- Good: policy enforces ownership regardless of client query
CREATE POLICY "Users read own notes"
  ON notes FOR SELECT
  USING (auth.uid() = user_id);
Enter fullscreen mode Exit fullscreen mode

4. Missing Policies on UPDATE and DELETE

A table has SELECT policies locked down. INSERT has a policy checking auth.uid() = user_id. But UPDATE and DELETE fall back to the table's default permissions—which often include the authenticated role via Supabase's default grants.

This means an authenticated user can update or delete any row in the table, even rows they don't own, because no policy restricts those operations.

Fix: Define policies for every operation your app uses. Be explicit.

ALTER TABLE notes ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users read own notes"
  ON notes FOR SELECT
  USING (auth.uid() = user_id);

CREATE POLICY "Users insert own notes"
  ON notes FOR INSERT
  WITH CHECK (auth.uid() = user_id);

CREATE POLICY "Users update own notes"
  ON notes FOR UPDATE
  USING (auth.uid() = user_id)
  WITH CHECK (auth.uid() = user_id);

CREATE POLICY "Users delete own notes"
  ON notes FOR DELETE
  USING (auth.uid() = user_id);
Enter fullscreen mode Exit fullscreen mode

Note the USING vs WITH CHECK distinction: USING filters visible rows for SELECT/UPDATE/DELETE; WITH CHECK validates new/updated rows for INSERT/UPDATE.

5. Policies That Reference Mutable Columns

A policy like USING (auth.uid() = user_id) assumes user_id never changes. If your app allows users to transfer ownership of a record (updating user_id), the policy evaluates against the new value during UPDATE—potentially letting a user reassign a row to themselves, then access it.

Similarly, if a policy references a column like team_id and that column can be updated, a user could move a row into their team and gain access.

Fix: For UPDATE policies, use USING to check the old row values and WITH CHECK to validate the new values. If ownership transfer is a feature, handle it through a controlled server-side function (via SECURITY DEFINER RPC) that bypasses RLS intentionally, not through direct client updates.

-- Safe update: user can only update rows they currently own
-- and cannot change ownership via this policy
CREATE POLICY "Users update own notes"
  ON notes FOR UPDATE
  USING (auth.uid() = user_id)           -- checks OLD row
  WITH CHECK (auth.uid() = user_id);     -- validates NEW row
Enter fullscreen mode Exit fullscreen mode

Bonus: Test Your Policies Like Code

RLS policies are code. They have logic, branches, and edge cases. But most projects have zero tests for them.

Supabase's local development stack (via supabase start) runs a real Postgres instance with the same RLS engine. You can write integration tests that:

  1. Create test users via supabase.auth.admin.createUser
  2. Sign in as each user to get JWTs
  3. Execute queries against the local DB with those JWTs
  4. Assert the correct rows are returned/modified

This catches regressions when you add columns, change policies, or refactor schemas.

// Example test pattern (Jest + supabase-js)
test('user cannot read another user\'s notes', async () => {
  const { data: userA } = await supabase.auth.admin.createUser({ email: 'a@test.com' });
  const { data: userB } = await supabase.auth.admin.createUser({ email: 'b@test.com' });

  const clientA = createClient(url, key, { global: { headers: { Authorization: `Bearer ${userA.session.access_token}` }}});
  const clientB = createClient(url, key, { global: { headers: { Authorization: `Bearer ${userB.session.access_token}` }}});

  await clientA.from('notes').insert({ content: 'secret', user_id: userA.user.id });
  const { data, error } = await clientB.from('notes').select('*');

  expect(data).toHaveLength(0); // RLS blocks access
});
Enter fullscreen mode Exit fullscreen mode

The Pattern Behind the Mistakes

Every mistake above stems from the same root cause: assuming the client is trustworthy or assuming defaults are secure. RLS shifts authorization to the database, which is the right architecture—but only if you treat policies as your primary security boundary, not a backup.

Audit your tables this week. Run:

SELECT
  schemaname,
  tablename,
  rowsecurity,
  (SELECT count(*) FROM pg_policies WHERE schemaname = t.schemaname AND tablename = t.tablename) as policy_count
FROM pg_tables t
WHERE schemaname = 'public'
  AND rowsecurity = true;
Enter fullscreen mode Exit fullscreen mode

Any table with policy_count < 4 (SELECT, INSERT, UPDATE, DELETE) likely has gaps.

Top comments (0)