← Back to blog

Vulnerability Module

Missing Supabase RLS: Detection and Remediation Guide

April 13, 2026 · 11 min read · PolyDefender Research Team

Tables without Row Level Security policies expose your entire database to unauthorized reads and writes. Here is the exact detection, patching, and validation workflow.

Supabase Row Level Security (RLS) is a PostgreSQL feature that determines which rows each database user can access. When it is disabled on a table, the Supabase anon key — which every browser-side Supabase client uses and which is safe to expose publicly — grants unauthenticated users direct SQL-level access to read and write every row in that table. In a multi-user application, this is complete data exposure.

How RLS Works and How Its Absence Creates Risk

Without RLS, the Supabase database behaves as if every request comes from a superuser. The anon key provides database connectivity with no access restrictions. Any JavaScript running in a user's browser — including JavaScript injected by a browser extension or malicious script — can execute arbitrary SELECT, INSERT, UPDATE, and DELETE statements against your Supabase project.

With RLS enabled and a correctly written ownership policy, the same anon key can only access rows where the policy condition is satisfied. For a typical ownership policy (auth.uid() = user_id), a user can only access their own rows — full stop. An attacker with the anon key gains nothing beyond what any authenticated user of your own app could access.

Which Tables Are Highest Risk

Not every table carries the same risk if RLS is missing. Prioritize tables that contain personally identifiable information, financial data, authentication data, or content created by specific users.

  • User profile tables (email addresses, names, profile data)
  • Order and transaction tables
  • Message and conversation tables
  • Document and file metadata tables
  • Tables that store authentication tokens, API keys, or session data
  • Tables that contain data from multiple users (any table with a user_id column)

Step 1: Detect Missing RLS Coverage

Run this query in the Supabase SQL Editor to see the RLS state of every table in your public schema:

SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public' ORDER BY rowsecurity ASC;

Tables with rowsecurity = false need immediate attention. Alternatively, use the Table Editor in the Supabase dashboard — the shield icon next to each table shows its RLS state at a glance. A PolyDefender scan will also automatically detect tables where RLS is disabled and surface them as Critical findings.

Step 2: Enable RLS and Write Policies

For each table that needs RLS, the process is: enable RLS (which creates a default-deny state), then add policies that allow the right access.

Enable RLS: ALTER TABLE tablename ENABLE ROW LEVEL SECURITY;

Add SELECT policy: CREATE POLICY "users view own rows" ON tablename FOR SELECT USING (auth.uid() = user_id);

Add INSERT policy: CREATE POLICY "users insert own rows" ON tablename FOR INSERT WITH CHECK (auth.uid() = user_id);

Add UPDATE policy: CREATE POLICY "users update own rows" ON tablename FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);

Add DELETE policy: CREATE POLICY "users delete own rows" ON tablename FOR DELETE USING (auth.uid() = user_id);

The auth.uid() function is provided by Supabase and extracts the authenticated user's ID from the JWT token. It cannot be tampered with from the client. Never use a user_id supplied as a query parameter in a policy — that can be manipulated.

Step 3: Handle Special Cases

Some tables require more nuanced policies than simple ownership:

  • **Shared resources**: For content accessible to members of a team or organization, write a policy that checks membership in a separate junction table: USING (EXISTS (SELECT 1 FROM team_members WHERE team_id = tablename.team_id AND user_id = auth.uid()))
  • **Public content**: For tables that should be publicly readable but only writeable by the owner, add a separate SELECT policy with USING (true) and keep the INSERT/UPDATE/DELETE policies requiring auth.uid()
  • **Admin access**: For tables that admins need to access across all users, create a separate admin role and add a policy that checks role membership: USING (auth.jwt() ->> 'role' = 'admin')

Step 4: Test Your Policies

A policy that compiles without errors is not necessarily a policy that works correctly. Test each table with two user accounts:

  • Create a record as user A
  • Authenticate as user B and attempt to SELECT, UPDATE, and DELETE user A's record
  • Confirm that all three operations return zero rows or a permission error
  • Confirm that user A can still access their own record

Add these tests to your CI pipeline as integration tests. Future AI-generated code changes that accidentally weaken your policies will fail these tests before reaching production.

Common RLS Policy Mistakes

  • Writing USING (true) as a policy — this means every authenticated user can access every row, which is usually a mistake left over from prototyping
  • Only writing a SELECT policy and forgetting INSERT, UPDATE, and DELETE
  • Using a joined table column in a policy without proper query structure — this can create unintended access patterns
  • Not enabling RLS on tables added after the initial security review — add RLS enablement to your table-creation template
Security Scan

Need a fast security baseline?

Run a free scan to detect secrets, auth bypass, RLS exposure, injection paths, and dependency risk in minutes.