Skip to content

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.

Vlad Tkachenko10 min read
A terminal window seen straight on, five short commands typed under an accent prompt, and a caret waiting on the sixth line.

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 apikey request 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.

Everything below is aimed at one of two addresses: your app's own, and your project's. Check 1 is what gets you the second one.

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 printsWhat it means
content-range: */0Nothing readable without a login. That table is doing its job.
content-range: 0-0/128128 rows reachable by anyone holding the key that ships in your page.
401 or 403The key was turned away before the table was reached. Unanswered, not clean.
404No table by that name. Either you guessed, or it is spelled differently.
Both requests succeeded. One header is the entire difference between a table doing its job and a table handing rows to anyone who asks.

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, orders and messages. 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.
The same five checks, plus four more, reported back. The score is 85 and the grade is a C, because one high finding caps the letter whatever the arithmetic says. The dashed row is the database, which never answered.

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.

Written by

Vlad Tkachenko

Founder, Reeve

I spend my time looking at apps built with Lovable, Bolt, v0, Cursor and Replit, and at the short list of mistakes that keep turning up in them.

More about the author

Read next

All articles

Not sure where your own app stands?

Run a free scan and get a plain-language grade from A to F in about 20 seconds. No account, no card.

Scan your app free

Automated external check, not a full audit. Absence of findings is not a guarantee of safety.