DEV Community

ShipSafeScan
ShipSafeScan

Posted on

Your RLS is on and the table is still readable

A while back I wrote up the pass I run over code an assistant wrote for me: secrets in git history, keys leaking into the client bundle, API routes with no guard, stale dependencies, wildcard CORS. Every one of those lives in your application code.

There is a layer underneath that, and it fails differently. If you are on Supabase, or any Postgres with Row Level Security, your database is reachable from the browser by design. The anon key is meant to be public. The thing standing between a stranger and your profiles table is not your login screen and not your API route. It is your RLS policies.

The failure I keep running into is not "I forgot about RLS." It is "RLS is on, the dashboard shows a green shield, and the table is still readable by anyone." Those are not the same state, and the dashboard does not really distinguish them for you.

One caveat before the commands: run these against a project you own. Pointing them at someone else's project is unauthorized access, and in most places that is a crime, not a code review.

The one test that matters

Forget reading the policy list for a minute. The question a stranger asks your database is very simple, so ask it the same way they would: with the public key, no session, from outside your app.

# Project ref and anon key are both in your client bundle already.
# This is exactly what an unauthenticated visitor can send.
curl "https://<project-ref>.supabase.co/rest/v1/profiles?select=*" \
  -H "apikey: <anon-key>" \
  -H "Authorization: Bearer <anon-key>"
Enter fullscreen mode Exit fullscreen mode

Three outcomes:

  • [] with a 200. The table is protected and empty for this caller. Good.
  • A permission error mentioning row level security. Also good.
  • Rows. Your data is public, whatever the dashboard says.

Run it for every table that holds anything user specific. It takes about ten seconds per table and it answers the real question, which is not "did I enable a feature" but "can a stranger read this."

Then do the same for writes, because read policies and write policies are separate things and I have shipped tables where only one of them was covered:

# Can an anonymous caller insert?
curl -X POST "https://<project-ref>.supabase.co/rest/v1/profiles" \
  -H "apikey: <anon-key>" -H "Authorization: Bearer <anon-key>" \
  -H "Content-Type: application/json" \
  -d '{"display_name":"anon write test"}'

# Can an anonymous caller delete someone else's row?
curl -X DELETE "https://<project-ref>.supabase.co/rest/v1/profiles?id=eq.<some-id>" \
  -H "apikey: <anon-key>" -H "Authorization: Bearer <anon-key>"
Enter fullscreen mode Exit fullscreen mode

Clean up any rows you create while testing.

Why it happens

Four patterns account for most of what I find.

1. RLS is off on that table. A table created from the SQL editor does not get RLS just because your other tables have it. When RLS is off, policies on the table are irrelevant, and the anon role reads everything. Check the flag directly instead of trusting your memory:

select c.relname as table_name, c.relrowsecurity as rls_enabled
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r'
order by c.relrowsecurity, c.relname;
Enter fullscreen mode Exit fullscreen mode

Anything with rls_enabled = false is open to the anon key.

2. A policy that allows everything. This is the one that produces the green shield and the public table at the same time. When you are stuck and asking an assistant why your query returns nothing, the easiest fix to reach for is a policy that permits the read unconditionally. It works, the error goes away, and the door is now open.

-- Enabled, has a policy, protects nothing.
create policy "enable read access for all users"
  on public.profiles for select
  using (true);
Enter fullscreen mode Exit fullscreen mode

List what you actually have:

select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, cmd;
Enter fullscreen mode Exit fullscreen mode

Read the qual column. If it says true on a table with user data, that is your bug. The ownership version is what you usually meant:

create policy "read own rows"
  on public.profiles for select
  to authenticated
  using (auth.uid() = user_id);
Enter fullscreen mode Exit fullscreen mode

3. Some commands are covered and others are not. Policies are per command. A table can have a careful select policy and nothing for update, which means the read is locked down and the row is still editable. Group the pg_policies output by table and check that every command you allow from the client has a policy behind it. Note that using decides which existing rows a statement can touch, while with check decides what a row is allowed to look like after an insert or update. Write policies generally need both.

4. The service role key is in the client. The service role key bypasses RLS completely, by design, so it belongs on a server and nowhere else. If it ends up in a component, an edge config, or anything with a public env prefix, every policy above stops mattering. In my own project the server client is a separate module with a comment at the top saying it must never be imported from client code, and the key is read from a non public env var. That comment has saved me at least once.

# Should return nothing outside server-only files
grep -rn "SERVICE_ROLE\|service_role" src/ | grep -v "server\|lib/db"
Enter fullscreen mode Exit fullscreen mode

What I do now

Before anything with real users touches it:

  1. List tables where relrowsecurity is false. Enable RLS on every one that holds user data. Enabling RLS with no policies denies access, which is the safe direction to fail.
  2. Dump pg_policies and read every qual. Replace each true with an ownership check.
  3. For every table, confirm there is a policy for each command the client is allowed to run, and that write policies carry with check.
  4. Run the logged out curl against every table, read and write, and keep going until the only tables that answer are the ones meant to be public.
  5. Grep for the service role key outside server code.

The part I want to leave you with is step 4. Policy review is reading, and reading is where you see what you intended. The unauthenticated request is the only one of these that tells you what you actually shipped.

If you want the application layer pass that goes with this, the earlier post covers secrets, bundles, route guards, and dependencies. I also put the checks I run most often into a scanner you can point at a public repo at https://shipsafescan.com

Top comments (0)