Guide Supabase
How to check a Supabase app is safe before launch
Short answer
Run seven checks on your own project. 1) Open the Security Advisor and fix every error. 2) List which tables have RLS on and which roles can read them. 3) Call your API with only the public key, logged out, and confirm private tables return nothing. 4) Sign in as two test users and confirm neither can read or change the other's rows. 5) Check which storage buckets are public. 6) Search your shipped JavaScript for secret keys. 7) Make sure every exposed table has both a grant and RLS.
Before you start: test only your own project
Supabase allows this kind of testing: "Customers of Supabase are permitted to carry out security assessments or penetration tests of their hosted Supabase project components." It prohibits "testing that directly targets other Supabase customers' accounts, organizations, and projects not under the customer's control", plus denial-of-service testing and request flooding. (Supabase: Security testing) Use test accounts and fake data.
1. Run the Security Advisor
In the Supabase dashboard, open Advisors, then Security Advisor (direct link). Advisors "are deterministic checks that ship with the platform." (Supabase: Advisors) The error to look for first is "RLS Disabled in Public": "Anyone with your project URL can read, edit, and delete all data in this table because Row-Level Security is not enabled."
Fix every error, then keep going. The Advisor is a good start, not the whole test. On our own demo project, Supabase's Security Advisor flagged the table with RLS turned off. It didn't flag a table whose RLS policy lets anyone read every row, because its always-true-policy check skips read policies by design, and it doesn't flag public buckets that serve files by link, which is how public buckets are meant to work but easy to misuse. (Demo write-up)
2. List your tables, RLS and who can read them
Run this in the SQL editor (an example; adapt it to your tables):
select c.relname as table_name,
c.relrowsecurity as rls_on,
has_table_privilege('anon', c.oid, 'select') as anon_can_select,
has_table_privilege('authenticated', c.oid, 'select') as signed_in_can_select
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;
Any row with rls_on = false and anon_can_select = true is readable by anyone with your public key. If it's signed_in_can_select = true instead, anyone who can sign up can read it. The query only lists tables. Views can bypass RLS, so check those too. Then read your policies: look for using (true) on anything private.
select tablename, policyname, roles, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, cmd;
3. The logged-out test
Your publishable (or legacy anon) key is in your app's JavaScript, so treat it as known. Supabase: "Anyone can read it, so it only reaches what Row Level Security allows." (Supabase: API keys) Call each private table with only that key:
curl "https://YOUR_PROJECT.supabase.co/rest/v1/orders?select=*&limit=5" \
-H "apikey: YOUR_PUBLIC_KEY"
200with[]means no rows came back. That's what you want for private tables, as long as the table really has rows (check in the Table Editor). If RLS is on and no policy lets this visitor read, you get the same empty[], not an error.401with code42501("permission denied") means logged-out visitors have no grant on the table. Also fine for private tables.200with real rows means anyone can read them. Fix RLS before launch.404with codePGRST205means the table name is wrong or the table isn't in your API. Use your own table names, notorders.401with "Invalid API key" or "No API key found" means the key is wrong. It tells you nothing about the table, so fix the key and run it again.
What a leak looks like (our demo project, fake data, RLS on with a using (true) policy):
$ curl "https://YOUR_PROJECT.supabase.co/rest/v1/demo_orders?select=*&limit=5" -H "apikey: sb_publishable_…"
HTTP/2 200
[{"id":1,"customer_email":"ava@example.com","amount_usd":49.00,"status":"paid", …},
{"id":2,"customer_email":"ben@example.com","amount_usd":0.00,"status":"trial", …}, …]
Supabase explains the difference: "A missing grant returns a permission error, including for service_role, whereas a policy that matches no rows returns an empty result." (Supabase: API keys)
4. The two-account test
RLS can be on and still wrong. Create two test users, A and B. As A, create a row in a private table. Then sign in as B, copy B's access token (from the session, for example with supabase.auth.getSession()), and try to read A's row:
curl "https://YOUR_PROJECT.supabase.co/rest/v1/orders?id=eq.A_ROW_ID&select=*" \
-H "apikey: YOUR_PUBLIC_KEY" \
-H "Authorization: Bearer USER_B_ACCESS_TOKEN"
You want []. Then try to change it:
curl -X PATCH "https://YOUR_PROJECT.supabase.co/rest/v1/orders?id=eq.A_ROW_ID" \
-H "apikey: YOUR_PUBLIC_KEY" \
-H "Authorization: Bearer USER_B_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d '{"status":"test"}'
Again you want []. A blocked update usually gives no error at all: it just changes zero rows. So check as A that the row is unchanged. Repeat for insert and delete on tables where it matters.
5. Check storage
Supabase buckets "are private by default". For a public bucket, "anyone who possesses the asset URL can readily access the file". (Supabase: Storage Buckets) List them, for example:
select id, public from storage.buckets order by public desc, id;
Public is fine for logos and avatars. Anything personal belongs in a private bucket, served with signed URLs. Assets in a private bucket "are not accessible via a public URL like the public buckets." (Supabase: Serving assets) Step by step: Is my Supabase storage bucket public?
6. Search your JavaScript for secret keys
"A secret key bypasses every Row Level Security policy you have. Never put one in a browser, a shipped application, or source control." (Supabase: API keys) Watch your env vars too: Vite builds VITE_ values into the code your visitors download. (Vite: Env Variables)
This script downloads the scripts your homepage loads and looks for new-style secret keys, plus legacy JWT keys with the service_role role:
SITE="https://your-app.example.com"
curl -s "$SITE" -o page.html
grep -oE '(src|href)="[^"]+\.js(\?[^"]*)?"' page.html | cut -d'"' -f2 | sort -u |
while read -r p; do
case "$p" in http*) u="$p" ;; *) u="$SITE/${p#/}" ;; esac
curl -s "$u"
done > bundle.js
grep -oE 'sb_secret_[A-Za-z0-9_-]+' bundle.js | sed -E 's/^(sb_secret_).*(.{4})$/\1…\2/'
grep -oE 'eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+' bundle.js | sort -u |
while read -r jwt; do
p=$(echo "$jwt" | cut -d. -f2 | tr '_-' '/+')
while [ $(( ${#p} % 4 )) -ne 0 ]; do p="$p="; done
echo "$p" | base64 -d 2>/dev/null | grep -o '"role": *"[a-z_]*"'
done
"role":"anon" is the public key and is fine. Any sb_secret_ match or "role":"service_role" means a secret key shipped. Remove it, then rotate it using Supabase's leaked-key steps. The script only sees scripts the homepage links to. So also click through the app and search your browser's dev tools (Sources). The script saves page.html and bundle.js in the current folder. Delete them when you're done, since any leaked key would be in them.
7. Grants plus RLS, and the Oct 30 change
Grants decide whether a role can reach a table. RLS decides which rows it can see. Supabase: "Use both controls for every exposed object." (Supabase: Securing your API)
From Oct 30, 2026, Supabase will apply a change to existing projects: new tables you create in the public schema will "require an explicit opt-in (via a Postgres grant) before the Data API can see them." Existing tables will keep their current grants. Until then, the Security Advisor flags affected tables and shows the remediation SQL. (Supabase changelog)
Supabase's fix treats grant, RLS and policies as one unit, because "If the grant is missing, Postgres rejects the query before RLS comes into play." For example, for a new private table (adapt it to your tables):
grant select, insert, update, delete on public.your_table to authenticated;
grant select, insert, update, delete on public.your_table to service_role; -- if server code uses your secret key
-- if the id is serial/bigserial (not identity), also:
-- grant usage, select on sequence public.your_table_id_seq to authenticated;
alter table public.your_table enable row level security;
-- then add a policy per action, limited to the row's owner
Only grant anon what logged-out visitors should see. More on the error: Supabase error 42501.
Why the logged-out test matters
One number: In Symbiotic Security's scan of 1,072 vibe-coded sites that use Supabase, 172 let anyone delete database records without authentication, 172 allowed unauthenticated PATCH (modification), 39 had tables fully readable with the public Supabase key, and 14 allowed unauthenticated inserts. (Source: Symbiotic Security, Jun 2, 2026. Symbiotic sells security scanning, so treat this as vendor research.)
FAQ
Is the Security Advisor enough?
It's the right first step, but it doesn't test what a logged-out visitor or a second user can actually reach. Steps 3 and 4 do.
Is it safe to have the anon key in my frontend?
Yes, that's what it's for. Your RLS policies are what protect the data. The secret key must never be there.
Do I need to do this again after launch?
Yes, after any change that adds tables, policies, buckets or env vars. From Oct 30, also check that each new table has the grants it needs.
I use Lovable Cloud. Does this apply?
The ideas do. For where each setting lives in Lovable, see the Lovable security checklist.
Want a second pair of eyes?
Free check: a passive check of your public homepage, its scripts and its security headers: exposed secret keys, missing headers and public storage links found in the code (the bucket itself is never opened or listed). No database queries, no logins, no changes. It covers part of step 6 (your homepage and up to 10 of its scripts) and can't run the other steps for you. Run the free check
Rather have it fixed? AI agents run the checks and a human engineer approves every change.
- Security Fix, $149 one time: RLS fixes on every table, storage bucket policies, a scan for exposed keys, key rotation guidance and a short report.
Every critical issue on our checklist fixed within 48 hours of working access, or a full refund. Refund Policy · Compare both packages