Your first GitHub repository audit is free.No card. Every finding included.
aster

THE FULL GUIDE / EP 02 · ASTER × GROK

Strangers can see your users? The Supabase RLS guide

Your Supabase key is public by design. Row Level Security is the only thing standing between it and every row of your users table. Here’s how to turn it on, write policies that work, and prove it as a stranger.

Security Guide7 min readBy Aster
Voxel Aster looking worried next to Grok, a black ball with slanted white eyes, in front of a neon club called The Database with a velvet rope and a bouncer.

Commented VIP? Here’s everything we promised: the checklist, the code, and why each step matters.

Jump to a story
  1. The checklist
  2. 01What happens when RLS is off
  3. 02Find every table without RLS
  4. 03Turn on Row Level Security
  5. 04Write policies: everyone sees only their own rows
  6. 05Test it like a stranger would
  7. 06Mistakes that quietly bypass RLS
  8. FAQ
  9. Source notebook
01

What happens when RLS is off

1 keyshipped to every browser
0 policiesneeded to leak a table
0013the Supabase advisor that flags it

Supabase gives your frontend a key that’s meant to be public: the publishable key (sb_publishable_…), or the legacy anon key. It goes into your JavaScript, every visitor gets a copy, and that’s fine. It’s the ticket everyone gets at the door of the club.

What decides where that ticket gets you is Row Level Security (RLS). Supabase exposes the tables in your public schema through its Data API. When RLS is off on a table, the API doesn’t check who’s asking, so anyone holding the public key can read the whole table. Supabase’s own security advisor puts it bluntly: anyone with your project URL can read, edit, and delete all data in that table.

Here’s all it takes. Your project URL and public key are already in your site’s JavaScript:

anyone’s terminal
# What a stranger can do when RLS is off on your users table
curl 'https://YOUR-PROJECT.supabase.co/rest/v1/users?select=*' \
  -H 'apikey: YOUR_PUBLISHABLE_KEY'

# → every row: ids, emails, names…

Same thing in one line of JavaScript: supabase.from('users').select('*') from the browser console of your own site.

THE BUILDER’S TAKEAWAYThe public key is not the bug. A table without RLS is the bug. The key is the ticket; RLS is the bouncer.

02

Find every table without RLS

Two ways to check, and it takes under a minute. In the dashboard, open Advisors → Security Advisor and look for RLS Disabled in Public (lint 0013_rls_disabled_in_public). Or run this in the SQL editor:

SQL editor
-- Tables in the public schema with Row Level Security turned OFF
select schemaname, tablename
from pg_tables
where schemaname = 'public'
  and rowsecurity = false
order by tablename;

You want zero rows. Every table listed here is readable by anyone with your public key.

While you’re in Advisors, also check Security Definer View (0010) and Exposed Auth Users (0002). Both are ways data slips past RLS through a view.

THE BUILDER’S TAKEAWAYMake “zero tables with RLS off” a launch requirement, the same as “the build passes”.

03

Turn on Row Level Security

Enabling RLS is one line per table:

migration.sql
alter table public.profiles enable row level security;
alter table public.orders   enable row level security;

Or switch it on for every table in public at once:

migration.sql
do $$
declare t record;
begin
  for t in
    select tablename from pg_tables
    where schemaname = 'public' and rowsecurity = false
  loop
    execute format('alter table public.%I enable row level security', t.tablename);
  end loop;
end $$;

THE BUILDER’S TAKEAWAYRLS on with no policies is “deny everything”, the safest starting point. You open access one policy at a time.

04

Write policies: everyone sees only their own rows

A policy is a rule Postgres adds to every query. using (…) decides which existing rows are visible or editable. with check (…) decides which new or changed rows are allowed. The classic rule for personal data is “a row belongs to the user whose id is on it”:

policies.sql
create table public.profiles (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null default auth.uid() references auth.users on delete cascade,
  display_name text
);
alter table public.profiles enable row level security;

create policy "Users can view their own profile"
on public.profiles for select
to authenticated
using ( (select auth.uid()) = user_id );

create policy "Users can create their own profile"
on public.profiles for insert
to authenticated
with check ( (select auth.uid()) = user_id );

create policy "Users can update their own profile"
on public.profiles for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

create policy "Users can delete their own profile"
on public.profiles for delete
to authenticated
using ( (select auth.uid()) = user_id );

-- policies filter on user_id, so index it
create index profiles_user_id_idx on public.profiles using btree (user_id);

Why every detail is there

  • to authenticated skips the policy entirely for logged-out visitors (anon), which is faster and clearer.
  • (select auth.uid()) instead of auth.uid(): wrapping it lets Postgres compute your user id once per query instead of once per row. Supabase recommends it for performance.
  • with check on insert and update: without it, a user could write a row with someone else’s user_id.
  • An index on user_id: the policy filters on that column for every query, so an unindexed column turns reads into full table scans.

Public content is a policy too

Not everything is private. A blog, a product catalogue or a public profile page can be readable by everyone, but only the parts you choose:

policies.sql
create policy "Anyone can read published posts"
on public.posts for select
to anon, authenticated
using ( published = true );
Don’tpolicies.sql
-- RLS is "on", but this lets everyone do everything
create policy "allow all"
on public.profiles for all
using ( true );
Dopolicies.sql
-- Scoped to the owner, per action, per role
create policy "Users can view their own profile"
on public.profiles for select
to authenticated
using ( (select auth.uid()) = user_id );

THE BUILDER’S TAKEAWAYOne policy per action, scoped to the owner, with the role named. using (true) is a velvet rope with no bouncer.

05

Test it like a stranger would

Don’t trust the dashboard, which uses elevated access. Test with the same public key your site ships, and with no session:

terminal
# Logged-out stranger: should return [] (or a permission error), never real rows
curl 'https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*' \
  -H 'apikey: YOUR_PUBLISHABLE_KEY'

Then check a signed-in user only sees their own rows. In the SQL editor you can impersonate one inside a transaction that rolls back:

SQL editor
begin;
  set local role authenticated;
  set local request.jwt.claims to '{"sub": "PASTE-A-USER-UUID", "role": "authenticated"}';

  select * from public.profiles;   -- only that user's row
  select count(*) from public.profiles where user_id <> 'PASTE-A-USER-UUID';  -- 0
rollback;
rls.test.ts
import { createClient } from '@supabase/supabase-js'

// Same URL + publishable key the browser uses, no session
const stranger = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_PUBLISHABLE_KEY!)

const { data } = await stranger.from('profiles').select('*')
if (data && data.length > 0) throw new Error('RLS leak: a stranger can read profiles')

Run a test like this in CI for every table that holds personal data.

THE BUILDER’S TAKEAWAYIf a logged-out request with your public key returns real rows, you have a leak. Test it the way an attacker would.

06

Mistakes that quietly bypass RLS

MistakeWhy it leaksFix
Secret / service_role key in the browserIt uses a Postgres role with bypassrls. Every policy is skipped.Server only. The browser gets the publishable key.
A view in publicViews run as their owner by default and can ignore the RLS of the tables underneath.Create views with (security_invoker = true) (Postgres 15+).
Policy based on user_metadataSigned-in users can edit their own user_metadata.Use app_metadata or a roles table you control.
using (true) on personal dataRLS is on, but the policy lets everyone in.Scope to (select auth.uid()) per action.
Insert or update without with checkUsers can write rows that belong to someone else.Add with check matching the owner rule.
views.sql
create view public.my_orders
with (security_invoker = true)
as select id, total, created_at from public.orders;

And renaming the table doesn’t help

In the episode, Aster tries to rename his table users_definitely_not_emails_v2_final. Grok’s verdict: “Bro thinks security through obscurity is a personality.” Your frontend calls supabase.from('…') with the real table name, so the name is in your JavaScript too. A confusing name is not a bouncer.

THE BUILDER’S TAKEAWAYRLS protects tables. Keys, views and metadata can route around it, so check those too.

QUESTIONS PEOPLE ASK

FAQ

What is Row Level Security in Supabase?

Row Level Security (RLS) is a Postgres feature Supabase uses to decide which rows each request can read or change. You enable it per table and write policies, for example (select auth.uid()) = user_id, that Postgres adds to every query.

Is the Supabase anon key safe to expose?

Yes. The publishable key and the legacy anon key are designed to be public, but only if RLS is enabled with good policies on every exposed table. The secret key and service_role key bypass RLS and must stay on the server.

Why does my Supabase query return an empty array after enabling RLS?

With RLS on and no matching policy, Postgres returns no rows. That’s the safe default. Add a select policy for the role making the request (usually authenticated), and check your user is actually signed in.

Does the service_role key bypass RLS?

Yes. The secret and service_role keys use a Postgres role with the bypassrls attribute, so every policy is skipped. Only use them in trusted server code.

Are tables created in the Supabase SQL editor protected by default?

No. The Table Editor enables RLS for new tables by default, but tables created with SQL, migrations or external tools need alter table … enable row level security; yourself.

Does RLS slow down my queries?

It can if policies are written naively. Wrap functions as (select auth.uid()), name the role with to authenticated, and index every column your policies filter on.

How do I check which Supabase tables have RLS disabled?

Open Advisors → Security Advisor and look for “RLS Disabled in Public”, or run select tablename from pg_tables where schemaname = 'public' and rowsecurity = false; in the SQL editor.

THE BIGGER PICTURE

The takeaway for builders.

Supabase is safe to use from the browser, but only with the bouncer on the door. Turn on RLS for every table, give each one a guest list with owner-scoped policies, keep the secret key on the server, and test as a stranger before every launch. A free Aster repository audit can take a second look at the code side, like where your keys end up and how your client talks to Supabase.

The source notebook 5 links

Go a little deeper. These are the sources linked in this story.

  1. 01Supabase: understanding API keyssupabase.com
  2. 02Supabase: Performance and Security Advisorssupabase.com
  3. 03Supabase: Row Level Securitysupabase.com
  4. 04PostgreSQL: row security policiespostgresql.org
  5. 05PostgreSQL: CREATE POLICYpostgresql.org

PUT THE CONTEXT TO WORK

A second look.
Before you ship.

Connect your GitHub repository and see what needs attention. Your first audit is free.

Start my free audit

KEEP THE CURIOSITY GOING

Your next read.

YOUR FEED, WITH A LITTLE MORE CONTEXT

See it. Save it.
Build something better.

Catch the short version on your favorite platform.
Come back here for the details and sources.

Aster

Stay secure.
Ask away.

Hi, I’m Aster. I can help with your first audit, plans, or connecting GitHub.

Prepared site guidance