On 11 August 2026 two apps I had permission to scan came back from our scanner with the same two rows: "Database access: Not applicable" and "Storage buckets: Not applicable". Both apps run on Supabase and call it from the browser, so neither row could have been right. Working out why turned up three separate defects, and each one turned a question we could not answer into a clean result.
The scanner checks an app from outside, with what any visitor has: the page, the JavaScript it loads, and the publishable key that ships in that JavaScript. A check can end three ways. It found something, it looked and found nothing, or it could not look. We print the second as "All good" or "Not applicable" and the third as "Couldn't check", and all three defects below turned the third into the second.
Every Supabase response in this post was measured that day against real projects, so some of them may have changed since.
1. The table list came back empty
The RLS check used to start by asking the PostgREST root, /rest/v1/, for its OpenAPI document, then probe each table it listed. On a project using Supabase's current key format, that root answers a publishable key like this:
401 {"message":"Secret API key required"}
The list was empty, nothing got probed, and the check returned no findings, which the scanner prints as "Not applicable". Our main check had no coverage at all on any app that ships one of the new publishable keys.
Discovery is now a union. The root document is still tried first, because a legacy project still serves it and its list is authoritative. After that come the table names the app's own bundle uses (.from("x") calls and /rest/v1/x paths), then 26 conventional names, up to 30 probes in all. Thirty came from the clock: a wave of four probes through our proxy measured about 480 ms, so 30 names take about four seconds of a 12-second check budget. For one day the cap was 20, because a stale .env on the scanning machine still held the old 5-second budget.
If none of the 30 names exists, the check now prints "Couldn't check". It knows the app has a database and has learned nothing about it.
RLS answers 200 either way
Fixing discovery uncovered a second problem in the same file. The old code counted any 200 from a table as readable by strangers. Row Level Security filters rows instead of refusing the request, so a protected table answers 200 with content-range: */0, the same status a wide-open table gets. On one of the two apps, profiles, notes, decks and flashcards all answered 200 and all said */0. Under the old rule every one of them would have been reported as "Strangers can read your private database tables", at critical severity because profiles holds people, about four tables that were in fact protected.
That would have been a false alarm, the opposite failure. On these apps it never fired, because the empty table list meant no probe reached it.
The probe asks for a count and never for rows: ?select=count with Prefer: count=exact and Range: 0-0. The verdict is the number after the slash in content-range. Above zero is a finding. Zero means the table exists and nothing came back. A missing or unreadable header means we learned nothing, and it is never a finding in either direction.
Later in August we ran it over 30,998 live apps. Of the 8,435 that named a Supabase project in the page, the check could finish on 3,680, and the figure we published is over those alone: Can anyone read your Supabase database? We checked 3,680 apps.
2. The bucket check never sent a body
Storage lists a bucket through POST /storage/v1/object/list/<bucket>. The options type of our HTTP helper had no body field, so the POST went out empty, and Supabase answers an empty POST with 400 "Body cannot be empty". It did that for every bucket name on every project, for the whole life of the check, and since a 400 is not a listable bucket, every time the check got as far as asking, it printed "Your uploaded files are kept private".
The unit test passed the entire time. Its fixture answered 200 whatever the body was, because it encoded what we believed about the endpoint.
Adding the body was only half of it. A bucket that does not exist also answers 200 [], the same as a private one, so with the body added and the old rule kept (a 200 with any array counted), the check would have flagged all seven names on its list on every project. Listable now means a 200 with a non-empty array, and the body asks for one entry at most:
{"prefix":"","limit":1}
Before committing we broke each fix on purpose to see whether the tests would notice. Dropping the request body fails exactly the four bucket-listing tests, and counting any 200 as exposed fails exactly the three count-verdict tests.
3. The page never loaded
This one sat under the other two. The scanner loads the page in headless Chromium and collects the JavaScript it really runs, and the Supabase URL and key come out of that bundle. When the load failed, the scan runner caught the error and handed every check an empty bundle without telling anyone. The secrets check then printed "No exposed API keys found" and the source-map check printed "Your original source code isn't published", about code nobody had downloaded. With no bundle there was no Supabase URL either, and the two Supabase checks took that to mean the app had no Supabase. Both stored scans behind the report had the secrets check down as clean, which is why nobody suspected the rest.
Treating every empty bundle as a failure would have been wrong too, because a page that ships no JavaScript is a real result. The runner now sets collectFailed when the load fails, and the four checks that read the bundle (secrets, source maps, RLS and buckets) call assertCollected() first. It throws, and a check that throws is printed as "Couldn't check".
The rule since
The rule we wrote down afterwards: an empty result means "we looked and found nothing", and nothing else may print as one. Every fixture involved had encoded a version of Supabase two API generations old, so when a vendor's response shape is in question we now check it against the live service before writing the code that reads it. Re-run after the fix, both apps got a real answer on both rows.
Two days later the same mistake turned up in our own statistics. The percentages we published divided every count by the apps whose scan returned, which counted every unanswered check as clean. Over the apps each check actually answered on, "95% of apps had at least one problem" became 98%.
I build Reeve, and these are the Supabase checks in its free scanner, which runs them from outside against a live URL: Reeve's security scanner.
Top comments (0)