Security basics
Supabase security checker: run the five checks yourself
A Supabase security checker reads your published app instead of your project settings. Here are the five checks it runs, and how to run each one yourself.

In short
- A Supabase security checker reads your live app the way a stranger does: the bundle it ships, the answers your tables give, your storage buckets, your headers.
- Supabase's own Security Advisor reads the other half, which is your project's configuration. Neither one sees what the other does.
- Five checks cover the outside view. Every one of them runs from a terminal, needs no account, and takes about ten minutes together.
You searched for a Supabase security checker and got nine of them back. One wants your repository. One wants a browser extension. One wants read access to your whole Supabase account, which is close enough to the thing you were worried about to give you pause.
Here is what none of those tool pages says out loud. There are five checks, and you can run every one of them yourself, with no account, no install, and none of the credentials that would be dangerous to hand over. Ten minutes in a terminal covers the same ground the tools do.
The five below are the ones our own scanner runs against a Supabase project, written out as commands. If you built with Lovable, Bolt, v0, Cursor, Replit, Windsurf or Base44 and your app stores anything at all, the database behind it is very likely Supabase, and these are the questions a stranger would put to it.
What does a Supabase security checker actually check?
Your published app. The settings inside your project are a different tool's job, and Supabase already ships that tool.
The Security Advisor in your dashboard reads your project's own catalogue: which tables have Row Level Security switched off, which functions have a loose search path, which extensions sit in the public schema. It is reading what you configured.
A checker reads what your app hands a stranger. The JavaScript bundle your visitors download, the answer a table gives a request with no login attached, the contents of a storage bucket, the headers on your pages. It sees none of your configuration and does not need to, because it is looking at the consequence of it.
The two halves come apart more often than the names suggest. A table can pass the Advisor with the switch on and a policy written, and still hand its rows to anybody, because the policy that got written allows everybody. That is worth reading about on its own if it is new to you: the toggle is not the protection.
Check 1: which Supabase key does your app ship?
Open your app in a browser, press F12, and look at one request.
Go to the Network tab, reload the page, and type supabase into the filter box.
Your app will make at least one request to an address ending .supabase.co.
Click it. Two things you need for the rest of this article are in there:
- The request address begins with your project URL, something like
https://abcdefghij.supabase.co. Checks 2 and 3 are aimed at it. - The
apikeyrequest header carries the key your app hands every visitor.
Copy both somewhere, then read the key itself. One beginning sb_publishable_
belongs in your app and there is nothing here to fix. One beginning sb_secret_
never does, and finding it ends the checklist early: rotate it today, ahead of
everything else on this page. Older projects carry a pair with no prefix to
read, anon and service_role, and telling those two apart means
reading the role inside the key.
That last one is rare. Across 30,998 live apps we scanned in August 2026, a Supabase secret key sitting in the browser turned up three times.
While the Network tab is open, write down the table names you can see in those request addresses. The next check needs them.
Check 2: will your tables answer a stranger?
Ask the database how many rows it would hand somebody with no login, and read the number off the response.
curl -s -i \
-H "apikey: YOUR_PUBLISHABLE_KEY" \
-H "Prefer: count=exact" \
-H "Range: 0-0" \
"https://YOUR_PROJECT.supabase.co/rest/v1/profiles?select=count" \
| grep -i content-range
Swap in the key and the project address from check 1, and put your own table
name where profiles is. curl comes installed on macOS, on Linux and on
current Windows, so there is nothing to fetch first.
No row is fetched by that request. select=count asks for a count,
Range: 0-0 asks for none of the rows themselves, and the whole answer arrives
in a single header. It is the same request our scanner makes, which is why the
scanner can run against somebody's live app without touching their data.
Four things can come back, and one of them is a finding:
| What prints | What it means |
|---|---|
content-range: */0 | Nothing readable without a login. That table is doing its job. |
content-range: 0-0/128 | 128 rows reachable by anyone holding the key that ships in your page. |
401 or 403 | The key was turned away before the table was reached. Unanswered, not clean. |
404 | No table by that name. Either you guessed, or it is spelled differently. |
The refusal is the row to be careful with. A 401 says the request stopped
before it ever reached the table, which tells you nothing about whether the
table is protected, and it is the easiest of the four to round up into a tick.
Run the command for every table your app named in check 1, then for the ordinary
ones an app tends to have: users, profiles, customers, orders,
messages, invoices. Those six are where somebody else's data lives.
This is the check that finds the most. Of the 3,680 Supabase-backed apps where we could complete it, 2,096 had at least one table answering a request with no login, and in 394 of those the open table was named after people. The full measurement and what it is a share of is a separate article.
Check 3: will a storage bucket list its own files?
Ask a bucket for its contents and see whether any come back.
curl -s -X POST \
-H "apikey: YOUR_PUBLISHABLE_KEY" \
-H "Content-Type: application/json" \
-d '{"prefix":"","limit":1}' \
"https://YOUR_PROJECT.supabase.co/storage/v1/object/list/avatars"
Send the body. A POST to that address with nothing in it comes back "Body cannot be empty" for every bucket in every project, no matter how they are configured. We know because our own bucket check shipped that way and spent months reporting cheerfully that nothing was listable.
Two shapes of answer:
[]means the bucket is locked, or there is no bucket by that name. From outside you cannot tell which, so an empty list is not evidence of anything.- An array with a file in it means a stranger can ask your project for a directory of that bucket and get one.
Try the bucket names your app used, then the common ones: avatars, public,
uploads, files, images, documents.
Listable and public are two different settings kept in two different places, and the difference decides how much a public bucket matters. Listing is the one to close, because it saves a stranger the work of guessing a filename. Of the 27,269 apps where we could ask, 792 answered with a list.
Check 4: is your source code published alongside your app?
Read the last line of your JavaScript bundle.
Back in the Network tab from check 1, find the largest .js file your app
loaded and copy its address. Then:
curl -s "https://your-app.example/assets/index-abc123.js" | tail -c 120
A last line reading //# sourceMappingURL=index-abc123.js.map means your build
wrote a source map and told browsers where it is. Whether it actually published
it takes one more request:
curl -s -o /dev/null -w "%{http_code}\n" \
"https://your-app.example/assets/index-abc123.js.map"
200 means anyone can download it. A source map turns the compressed
JavaScript back into your original files, with your folder structure, your
comments and your logic intact. What it exposes is your code, and any key it
reveals was already sitting in the bundle beside it, which is what check 1 was
for. 3,885 of the 30,987 apps we could check publish theirs, and on some
builders that is a platform default rather than a decision anybody made:
what a published map shows.
Check 5: what your host sends with every page
One request to your app's own address, reading what came back with it.
curl -s -i "https://your-app.example" | grep -i -E \
"content-security-policy|strict-transport-security|x-frame-options|x-content-type-options|referrer-policy"
Five headers, and the ones that do not print are the ones you are missing. They tell a browser to refuse the page if it is loaded inside somebody else's site, to insist on an encrypted connection next time, and to stop guessing at what kind of file it just received.
Almost every app fails this one and almost nobody can act on it. 30,756 of the 30,981 apps we could check were missing at least one, because the hosting platform sends these and an owner on a builder subdomain has nowhere to change them. We count it and cap it at medium: what missing headers do and do not predict.
Reading what you found
Severity order, which is not the order you ran them in.
A secret key in the bundle comes first. It skips every rule you wrote on every table, so rotate it before you look at anything else here.
A table full of people answering check 2 is next. users, profiles,
orders and messages hold somebody else's data and they are reachable right
now. A products table answering the same way may be exactly right, and you are
the only person who can tell the two apart.
Then a listable bucket, then maps and headers, which are usually your platform's defaults and not something you chose.
One thing to carry out of all five. A check that got no answer has not
passed. A 401, a request that timed out, a table whose name you guessed
wrong: each of those is a question still open. Our scanner prints "Couldn't
check" on those lines and leaves the grade alone, and doing this by hand means
keeping that list yourself.
Everything here also describes the app that was live at the moment you asked. Row Level Security gets switched off to make a page load, a bucket gets opened for one upload, a key gets pasted in so a feature ships tonight.
What to do this week
What to do
- Run check 2 against every table your app names, then against
users,profiles,customers,ordersandmessages. That is where the findings are. - If check 1 turned up a secret key, rotate it first. Deleting it from your code leaves the old value working for whoever already has it.
- Write down every probe that got a refusal or no reply. Those are unanswered, and an unanswered question is not a clean result.
- Leave the tables meant to be public alone. A product list answering a stranger is your app working correctly.
- Run all five again after a deploy and after anyone changes a database rule. Neither one announces itself.
Our own free security scanner runs these five and four more against any live URL in about twenty seconds, with no account and no credentials handed over. It reads, never writes, and where it cannot get an answer it says so on the line.
You can do all five yourself today. What no single pass can tell you is whether the answers are still the same next month, which is what we built Reeve Care to do: it re-runs these checks on a schedule, emails you when an answer gets worse, and keeps verified backups of your Supabase database, plus the files your users uploaded once you connect a storage credential. What it watches and what it costs.
If a terminal is not where you want to be, the plain-language walkthrough for Supabase apps covers what each of these settings is for and where to find it in the dashboard.
FAQ
Does the Supabase Security Advisor catch everything?
It catches everything in its own half. The Advisor reads your project's configuration and reports tables with Row Level Security switched off, functions with a loose search path, and similar settings you control from the dashboard. What it cannot see is your published app: which key ended up in the JavaScript your visitors download, whether a policy you wrote actually stops an anonymous request, or what headers your host sends. Run the Advisor and the five checks in this article, because they look at different things.
Is running these checks against my own project safe?
Yes. Every command here reads and none of them writes. The table check asks your database for a count of rows and reads the number out of a response header, so no row is ever fetched. The bucket check asks for a listing and stops there, without downloading a file. The last two are an ordinary page request of the kind your visitors make all day. Running them against a project you do not own is a different question, and the answer is to ask the owner first.
My anon key is in my bundle. Is that a problem?
No. The anon key in older projects, and sb_publishable_ in newer ones, is designed to sit in the code your visitors download. It names your project and grants nothing by itself; Row Level Security is what decides which rows a request gets back. The key that must never be there is the secret one, called service_role in older projects and sb_secret_ in newer ones, because it skips every rule you wrote.
What does content-range: */0 mean?
It is your database saying it will hand this caller zero rows from that table. The star means the response contains no rows at all, and the number after the slash is the count the caller is allowed to see. So */0 is the answer you want from a table holding personal data, and 0-0/128 means 128 rows are reachable by anyone holding the key that ships in your app.
Do I need my service_role key to test this?
No, and a checker that asks for one is asking for the wrong thing. Every check here uses the publishable key that is already in your app, because that is the key a stranger would have. Testing with a secret key tells you what an administrator can reach, which was never in question. Do not paste a service_role or sb_secret_ key into any scanner, ours included: nothing we run needs it.