Skip to main content
ZELSIS

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.

Query a table as an anonymous visitor
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.

Bad: everyone reads every profile
create policy "Profiles are viewable"
  on profiles for select
  using (true);
Good: each user reads their own row
create policy "Users read own profile"
  on profiles for select
  to authenticated
  using ((select auth.uid()) = id);
Test it: Run the anon curl above against each private table. The result should be [], 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.

Bad: migration errors out
create policy "Users insert own todos"
  on todos for insert
  using (auth.uid() = user_id);
Good
create policy "Users insert own todos"
  on todos for insert
  to authenticated
  with check ((select auth.uid()) = user_id);
Test it: Run 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.

Bad: any user can delete any file in the bucket
create policy "Users can delete files"
  on storage.objects for delete
  to authenticated
  using (bucket_id = 'documents');
Good: files live under the owner's folder
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)
Test it: Upload a file as user B. As user A, call 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.

Bad: bundled into the browser
'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!
);
Good: server-only module, no NEXT_PUBLIC_ prefix
// 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.
Test it: Run a production build, then search the output for the key: open your site, view the JS files in the browser's Network tab, and search for 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.

Bad
user_id uuid references auth.users(id)
Good
user_id uuid references auth.users(id) on delete cascade
Test it: Create a test user, create a row in every user-owned table for them, then delete the user from Authentication, Users. It should succeed. Full walkthrough: Supabase: can't delete user (foreign key violation).

Quick checklist

  • RLS is enabled on every table in the public schema.
  • 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