HullCheck Free check

Guide · Supabase · updated 25 September 2026

Is my Supabase app leaking?

How to check Row Level Security in about 15 minutes, for apps built with Cursor, Claude Code, Bolt, Codex, Lovable or Replit. Every step uses your own Supabase dashboard; nothing here needs a paid tool.

Why AI-built apps leak

A Supabase app ships two things to every visitor's browser: your project URL and a public key (the anon key, or the newer sb_publishable_… key). That's by design. Anyone can copy them from your JavaScript in seconds.

What stops a stranger from using that key to read your tables is Row Level Security (RLS): rules in the database that decide which rows each request may see or change. When RLS is off on a table in the public schema, that table can be read, and often edited, by anyone who has your public key.

AI app builders create tables quickly, and it's easy to end up with a table where RLS was never switched on, or a policy that says "everyone may read everything". In August 2026, Reeve passively scanned 30,998 live vibe-coded apps: of the Supabase apps they could reach, 57% (2,096 of 3,680) allowed table reads without logging in.1

1. Find tables without RLS

Open your project in the Supabase dashboard, go to the SQL Editor, and run:

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 in ('r', 'p')
order by c.relrowsecurity, c.relname;

Every row with rls_enabled = false is a table that your public key can reach without any rule. Unless it truly holds nothing but public data, turn RLS on (see Fix it).

Turning RLS on with no policies blocks all access through the API with the public key. That's safe, but your app may stop showing data until you add the right policies, so add them in the same change.

2. Run the Security Advisor

Supabase has a built-in linter. In the dashboard, open Advisors → Security Advisor. The findings that matter most here:2

  • 0013 RLS Disabled in Public: the same problem as step 1.
  • 0007 Policy Exists RLS Disabled: you wrote policies, but they do nothing because RLS is off on that table.
  • 0010 Security Definer View: a view that ignores RLS (see step 5).
  • 0023 Sensitive Columns Exposed: columns that look sensitive in a table the API can reach.

If you build with Lovable, its own security scan also looks for tables without row-level security and for rules that let everyone through.3 Run both; they're free.

3. Read your policies

"RLS enabled" isn't the same as "safe". A policy like using (true) lets every row through, and dashboards still show the table as protected. List your policies:

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

Look for these red flags:

  • qual is true on a SELECT policy for a table that holds anything private (users, profiles, orders, messages).
  • roles is {public} or includes anon on tables that only signed-in users should see.
  • An INSERT or UPDATE policy whose with_check is true or empty: people can write rows on behalf of someone else.
  • A policy that checks a column the user can change themselves, such as a role or is_admin column in their own profile row.

4. Test from the outside, like a stranger would

Only do this on your own project. Take the project URL and the public key from your app's settings, pick a table that should be private, and run this from a terminal:

curl "https://YOUR-PROJECT-REF.supabase.co/rest/v1/profiles?select=*&limit=1" \
  -H "apikey: YOUR-PUBLISHABLE-OR-ANON-KEY"
  • You get rows back: anyone on the internet can read that table. If it's meant to be public, such as published blog posts, that's fine. If it holds anything private, fix it now.
  • You get []: either the table is empty, or RLS hides every row from anonymous requests. Check in the Table Editor that the table has rows; if it does, you're protected for anonymous reads.
  • You get a permission error: the anonymous role can't access the table at all. Also fine.

This only tests anonymous access. Also think about signed-in users: can user A read user B's rows? Sign up two test accounts and try.

5. Views and functions that skip RLS

Views don't follow the RLS of their tables by default. On Postgres 15 and later you can make them respect it:4

alter view public.your_view set (security_invoker = true);

Functions marked security definer run with their owner's rights and can be called through the API (/rest/v1/rpc/…). List them:

select p.proname as function_name
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.prosecdef;

For each one, check that it verifies who is calling, or revoke access from the public roles:

revoke execute on function public.your_function from anon, authenticated;

6. Storage buckets

Files in a public bucket can be downloaded by anyone who has or guesses the URL. List your buckets:

select id, public from storage.buckets;

Keep public only what's meant to be public, such as product images. Invoices, uploads and avatars of private accounts belong in private buckets with policies on storage.objects.

7. Secret keys in your frontend

The service_role key (or a new sb_secret_… key) skips RLS entirely. It must never be in code that runs in the browser. The same goes for Stripe secret keys, OpenAI or Anthropic API keys, and similar.

Search your production build for them, for example grep -r "service_role\|sb_secret_\|sk_live_" dist/, or run HullCheck's free surface check on your own domain: it looks for known secret key formats in the JavaScript your site ships, tells a service_role key apart from the public anon key, and gives you a fix prompt. If a secret was ever public, rotate it, because removing it from the code doesn't un-publish it.

8. Fix it: policies you can paste

The most common case: each row belongs to one user through a user_id column. Replace todos with your table:

alter table public.todos enable row level security;

create policy "Owners can read their rows"
on public.todos for select to authenticated
using ((select auth.uid()) = user_id);

create policy "Owners can add their rows"
on public.todos for insert to authenticated
with check ((select auth.uid()) = user_id);

create policy "Owners can update their rows"
on public.todos for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);

create policy "Owners can delete their rows"
on public.todos for delete to authenticated
using ((select auth.uid()) = user_id);

Writing (select auth.uid()) instead of auth.uid() lets Postgres evaluate it once per query instead of once per row, and to authenticated keeps anonymous requests out of the policy altogether.4

For a table that really is public, such as a list of blog posts, allow reads and nothing else:

alter table public.posts enable row level security;

create policy "Anyone can read published posts"
on public.posts for select to anon, authenticated
using (published = true);

Prefer to let your AI tool do it? Paste this, then check the result with steps 1, 3 and 4 again:

Enable Row Level Security on every table in the public schema. For each table, tell me who should be able to read and change which rows, then write one policy per operation (select, insert, update, delete) that enforces exactly that. Use (select auth.uid()) and "to authenticated" for per-user data. Never use "using (true)" on tables that hold personal or private data. Don't use the service_role key in client code. Show me the SQL before running it.

The short checklist

  1. No table in public has RLS disabled, unless it's truly public data.
  2. The Security Advisor shows no 0013, 0007 or 0010 findings.
  3. No using (true) on private tables; inserts and updates have a with check.
  4. The curl test with your public key returns nothing private.
  5. Views use security_invoker; security-definer functions check the caller.
  6. Only truly public files sit in public buckets.
  7. No service_role or other secret key in your shipped JavaScript.

Sources

  1. Reeve, “The State of Vibe-Coded App Security 2026”, 19 August 2026. Passive scan of 30,998 live apps, 12–14 August 2026.
  2. Supabase docs, Database Advisors.
  3. Lovable docs, Security overview.
  4. Supabase docs, Row Level Security.