How to test Supabase RLS before you ship
Your Supabase anon key is public: it ships in the browser bundle and anyone can copy it. Row Level Security (RLS) policies are the only thing between that key and your tables. A policy that looks fine in the dashboard can still let every visitor read or edit every row. Below are five common mistakes, a fixed version of each, and a manual test you can run in a few minutes.
Before you start: get a test setup
You need your project URL, the anon key (Project Settings, API) and two test users, A and B. Most tests below are plain HTTP requests against the REST API. Without a user token they run as the anon role; with a user's access token they run as that user.
curl "https://YOUR_PROJECT.supabase.co/rest/v1/profiles?select=*" \
-H "apikey: YOUR_ANON_KEY"To act as user A, sign in with supabase.auth.signInWithPassword() in a scratch script and add -H "Authorization: Bearer USER_A_ACCESS_TOKEN" to the request.
1. USING (true) on a table with private data
USING (true) means "every row matches". It is fine for a public catalog such as products or prices. On a table with emails, orders, messages or anything per-user, it hands the whole table to anyone holding the anon key. On an UPDATE or DELETE policy it is worse: any client can change or wipe every row.
create policy "Profiles are viewable"
on profiles for select
using (true);create policy "Users read own profile"
on profiles for select
to authenticated
using ((select auth.uid()) = id);[], not rows. Then query as user A and confirm you never see user B's rows. Try a PATCH on B's row with A's token: it should update zero rows.2. INSERT policy written with USING instead of WITH CHECK
USING filters rows that already exist. New rows are checked with WITH CHECK. Postgres rejects an INSERT policy that has a USING clause ("only WITH CHECK expression allowed for INSERT"), so the migration fails and every statement after it in the same file is not applied. That can leave RLS enabled with no working insert policy, or leave later policies missing entirely.
create policy "Users insert own todos"
on todos for insert
using (auth.uid() = user_id);create policy "Users insert own todos"
on todos for insert
to authenticated
with check ((select auth.uid()) = user_id);supabase db reset locally and read the output; a failed migration stops there. Then, as user A, insert a row with user_id set to user B's id. It must be rejected with a row-level security error.3. Storage bucket policies scoped only by bucket
Storage uses RLS on storage.objects. A policy that only checks bucket_id applies to every file in that bucket, whoever uploaded it. For a private bucket (invoices, documents, IDs) that means any caller can list and download all files; for an UPDATE or DELETE policy it means any signed-in user can overwrite or delete other people's files.
create policy "Users can delete files"
on storage.objects for delete
to authenticated
using (bucket_id = 'documents');create policy "Users delete own files"
on storage.objects for delete
to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
-- upload with: supabase.storage.from('documents').upload(`${user.id}/${file.name}`, file)supabase.storage.from('documents').list(USER_B_ID), then download() and remove() on B's file path. All three should return nothing or an error. Also check the bucket is not marked public in the dashboard if it holds private files.4. Service-role key in client code
The service-role key bypasses RLS completely. If it reaches the browser, every policy above stops mattering. The usual ways it leaks: a NEXT_PUBLIC_ prefix on the variable, importing an admin client into a 'use client' component, or pasting the key straight into source.
'use client';
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!
);// lib/supabase/admin.ts
import 'server-only';
import { createClient } from '@supabase/supabase-js';
export const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
// Only call this after checking who the caller is.service_role or the first characters of the key. Nothing should match. Also search the repo for NEXT_PUBLIC_ variables containing SERVICE_ROLE or SECRET.5. Foreign key to auth.users without ON DELETE
This one is not a leak, but it breaks a feature you need for privacy requests: deleting an account. If a table references auth.users without an ON DELETE rule, deleting a user who owns rows there fails with a foreign key violation.
user_id uuid references auth.users(id)user_id uuid references auth.users(id) on delete cascadeQuick checklist
- RLS is enabled on every table in the
publicschema. - The anon key returns no rows from private tables.
- User A cannot read, update or delete user B's rows or files.
- All migrations apply cleanly from scratch.
- The service-role key is not in any browser bundle or
NEXT_PUBLIC_variable. - Deleting a test user with data succeeds.
How Zelsis catches this
Zelsis reads your SQL migrations and source files and flags these patterns with the file and line. The manual tests above are still worth running: static checks see your code, not the policies you changed by hand in the dashboard.
- GAP-17RLS policy lets anyone read every row (USING (true)) on a table with private data
- GAP-01RLS policy lets any client modify every row (USING (true))
- BUG-14INSERT policy uses USING instead of WITH CHECK (migration fails)
- GAP-21Storage policy lets every caller read all files in a private bucket
- NEXT-SB-05Storage policy lets any user overwrite or delete every file in the bucket
- SEC-36Public exposure of backend secret via NEXT_PUBLIC_ environment variable
- SEC-SECRET-18Supabase service role secret key in source
- BUG-17Foreign key to auth.users has no ON DELETE rule (user deletion fails)
Check your own repo
Public repositories scan free. Paste a GitHub URL on the dashboard and every finding comes with the file, the line and a fix.
Scan a repository free