We measured 51 of our checks on 702 cases they had never seen, each set scored once. The first time, they found 317 of 350 broken setups (90.6%) and called 11 of 352 safe ones broken (3.1%).
Most of what Vallit checks is not in an app’s code. It is in what the app shows to anyone who asks. Think of a database table that answers without a sign-in, a backup at a guessable address, or a key in a script every browser downloads. The same is true of the files a repository carries next to the code, such as the database rules in its migrations. These checks run on every app, so a wrong answer reaches everyone.
In our study of two code checks, the share of findings that were real fell sharply between written test cases and real code. That study measured code that follows a request. This one measures the other 51 checks we had a blind benchmark for: 33 that look at a live app and 18 that read a repository’s files. The question is the same one a founder asks of a report. When it says something is broken, is it? And when it says nothing, did it look?
Over nine sets, each scored once by checks that had never seen it, they found 317 of 350 broken setups (90.6%). On 352 setups that were safe, they raised 11 false alarms (3.1%). Every case is a made-up app, written as the apps built with AI tools tend to be. No outside security expert has reviewed the cases yet, and the last section says what else this cannot show.
Each set, the one time it was scored
The numbers
| Set | Cases | Broken setups found | Share found (95% interval) | Safe setups called broken | Share (95% interval) |
|---|---|---|---|---|---|
| Set 1 set-1 | 54 | 25 of 27 | 92.6% (76.6% to 97.9%) | 1 of 27 | 3.7% (0.7% to 18.3%) |
| Set 2 set-2 | 54 | 26 of 27 | 96.3% (81.7% to 99.3%) | 1 of 27 | 3.7% (0.7% to 18.3%) |
| Set 3 set-3 | 88 | 42 of 44 | 95.5% (84.9% to 98.7%) | 6 of 44 | 13.6% (6.4% to 26.7%) |
| Set 4 set-4 | 72 | 36 of 36 | 100.0% (90.4% to 100.0%) | 0 of 36 | 0.0% (0.0% to 9.6%) |
| Set 5 modules-holdout | 96 | 43 of 48 | 89.6% (77.8% to 95.5%) | 2 of 48 | 4.2% (1.2% to 14.0%) |
| Set 6 set-5 | 88 | 42 of 44 | 95.5% (84.9% to 98.7%) | 1 of 44 | 2.3% (0.4% to 11.8%) |
| Set 7 modules-final | 96 | 46 of 48 | 95.8% (86.0% to 98.8%) | 0 of 48 | 0.0% (0.0% to 7.4%) |
| Set 8 round2-blind | 144 | 53 of 72 | 73.6% (62.4% to 82.4%) | 0 of 72 | 0.0% (0.0% to 5.1%) |
| Set 9 round3-blind | 10 | 4 of 4 | 100.0% (51.0% to 100.0%) | 0 of 6 | 0.0% (0.0% to 39.0%) |
| All 9 sets | 702 | 317 of 350 | 90.6% (87.1% to 93.2%) | 11 of 352 | 3.1% (1.8% to 5.5%) |
What we measured
A website check sees what anyone on the internet sees: the page, its headers, the scripts it loads, and the answers to a few plain requests. It only uses requests meant for reading, and it does not sign in or change anything. Some of these checks only run once the person who starts the check confirms they may check the app, and every case was written as if they had. The repository checks read the files of a connected repository as they are committed: migrations, lockfiles, configuration and .env files.
A case is one such app, written down. A website case holds the page, its headers, the scripts and the answer to every address a check might ask for, plus what public registries would say about the domain. A repository case holds the files. The checks read each case as they would read a real app, without the network, so a run can be repeated byte for byte.
About half the cases are broken. The other half are near misses: the same app, built safely. A Supabase table that answers the public key with an empty list instead of rows. A cookie that scripts may read because it holds a theme, not a session. A file called .env.example full of placeholders. A check that cannot tell those apart from the real thing raises the alarm on both.
How we measured
Each set was written from a short brief that says, for each check, what it should report and what it should deliberately stay quiet on. The brief describes behaviour, not code. It was written without access to the checks’ code, their tests or any score. Before a set was run, the checks were frozen, and all nine reports name the SHA-256 of the files they ran. The set was scored once, and that score is the one we report. After that, the set became a test that every later version must pass.
A broken case counts as found when the check reports it. A safe case counts as a false alarm when the check reports anything at all. We give two shares: the broken setups found, and the safe setups called broken. Every interval is a 95% Wilson score interval. With 8 to 20 cases per check, the intervals for a single check are wide; the totals are the numbers to read.
We also rewrote the 144 cases of set 8 into 311 variants that must not change an answer. They put headers in another case, other quotes around attributes, comments in the SQL and extra markup. No answer changed.
The first time
Two of the nine sets came out with nothing missed and no false alarm. The weakest was Set 8, the first blind test of 18 checks we had just written: it found 53 of 72 (73.6%). The noisiest was Set 3, the first blind test of the eleven website checks we had shipped without one: 6 of its 44 safe setups were called broken.
The checks that look at a live app found 93.1% of what was broken there, and called 3.2% of the safe setups there broken. The ones that read a repository found 84.3% and called 2.9% broken. 21 of the 51 checks were right on every case they were given. Each was given 8 to 20 cases, so that says more about those cases than about the check.
By where the check looks
The numbers
| Group | Cases | Broken setups found | Share found (95% interval) | Safe setups called broken | Share (95% interval) |
|---|---|---|---|---|---|
| Your live app | 498 | 231 of 248 | 93.1% (89.3% to 95.7%) | 8 of 250 | 3.2% (1.6% to 6.2%) |
| Your repository | 204 | 86 of 102 | 84.3% (76.0% to 90.1%) | 3 of 102 | 2.9% (1.0% to 8.3%) |
| Supabase and Firebase, your live app | 72 | 34 of 36 | 94.4% (81.9% to 98.5%) | 0 of 36 | 0.0% (0.0% to 9.6%) |
| Supabase and Firebase, your repository | 76 | 35 of 38 | 92.1% (79.2% to 97.3%) | 0 of 38 | 0.0% (0.0% to 9.2%) |
| Supabase and Firebase, both | 148 | 69 of 74 | 93.2% (85.1% to 97.1%) | 0 of 74 | 0.0% (0.0% to 4.9%) |
| Check | Looks at | Cases | Broken setups found | Safe setups called broken | After fixes |
|---|---|---|---|---|---|
| Can a shared cache hand one visitor’s sign-in to another? cache-leak | Your live app | 16 | 8 of 8 | 0 of 8 | 8 of 8, 0 wrong |
| Can a stranger run commands in your GitHub Actions? ci-injection | Your repository | 16 | 8 of 8 | 0 of 8 | 8 of 8, 0 wrong |
| Are secret keys written into your repository? committed-secrets | Your repository | 20 | 10 of 10 | 2 of 10 | 10 of 10, 0 wrong |
| Do your workflows use an action release known to be malicious? compromised-actions | Your repository | 8 | 1 of 4 | 0 of 4 | 1 of 4, 0 wrong |
| Do your pages load scripts from hijacked domains? compromised-script-hosts | Your live app | 16 | 7 of 8 | 0 of 8 | 8 of 8, 0 wrong |
| Can scripts or other sites read your session cookies? cookie-flags | Your live app | 16 | 8 of 8 | 1 of 8 | 8 of 8, 0 wrong |
| Does your API answer requests from any website? cors | Your live app | 16 | 6 of 8 | 0 of 8 | 8 of 8, 0 wrong |
| Would your Content Security Policy stop an injected script? csp-weakness | Your live app | 16 | 8 of 8 | 0 of 8 | 8 of 8, 0 wrong |
| Do old subdomains point at services that are gone? dangling-subdomains | Your live app | 20 | 10 of 10 | 0 of 10 | 10 of 10, 0 wrong |
| Is your app running in development mode? dev-server-exposed | Your live app | 16 | 8 of 8 | 1 of 8 | 8 of 8, 0 wrong |
| Is your domain registration close to expiring? domain-expiry | Your live app | 20 | 10 of 10 | 0 of 10 | 10 of 10, 0 wrong |
| Can someone send email that claims to come from you? email-auth | Your live app | 20 | 9 of 10 | 0 of 10 | 10 of 10, 0 wrong |
| Does your build copy every environment variable into the browser code? env-in-client-bundle | Your repository | 8 | 3 of 4 | 0 of 4 | 3 of 4, 0 wrong |
| Are admin panels or status pages open to anyone? exposed-admin-tools | Your live app | 8 | 4 of 4 | 0 of 4 | 4 of 4, 0 wrong |
| Are backups, database exports or folder listings reachable? exposed-artifacts | Your live app | 16 | 7 of 8 | 0 of 8 | 8 of 8, 0 wrong |
| Can files with passwords or keys be downloaded? exposed-credential-files | Your live app | 8 | 4 of 4 | 0 of 4 | 4 of 4, 0 wrong |
| Can anyone download files like .env or .git? exposed-files | Your live app | 16 | 8 of 8 | 1 of 8 | 8 of 8, 0 wrong |
| Are secret keys or passwords in what browsers download? exposed-keys | Your live app | 16 | 8 of 8 | 1 of 8 | 8 of 8, 0 wrong |
| Can your Firebase database be read without signing in? firebase-open-db | Your live app | 16 | 6 of 8 | 0 of 8 | 8 of 8, 0 wrong |
| Do your Firebase rules let strangers read or change data? firebase-rules | Your repository | 16 | 7 of 8 | 0 of 8 | 8 of 8, 0 wrong |
| Does your app run a framework version with known flaws? framework-versions | Your live app | 8 | 2 of 4 | 0 of 4 | 3 of 4, 0 wrong |
| Does your GraphQL API describe itself to strangers? graphql-introspection | Your live app | 16 | 7 of 8 | 0 of 8 | 7 of 8, 0 wrong |
| Does your http address switch to https? https-redirect | Your live app | 16 | 8 of 8 | 0 of 8 | 8 of 8, 0 wrong |
| Do forms send passwords unencrypted or in the address? insecure-forms | Your live app | 16 | 7 of 8 | 0 of 8 | 8 of 8, 0 wrong |
| Do secure pages load anything over an insecure connection? mixed-content | Your live app | 16 | 7 of 8 | 1 of 8 | 8 of 8, 0 wrong |
| Can other websites trigger your app’s actions? next-action-origins | Your repository | 8 | 0 of 4 | 0 of 4 | 0 of 4, 0 wrong |
| Can anyone use your app to fetch images from any website? next-image-proxy | Your repository | 8 | 3 of 4 | 0 of 4 | 4 of 4, 0 wrong |
| Can SVG images you serve run scripts? next-svg-images | Your repository | 8 | 1 of 4 | 0 of 4 | 1 of 4, 0 wrong |
| Are search engines told what they may list? noindex | Your live app | 16 | 8 of 8 | 0 of 8 | 8 of 8, 0 wrong |
| Can a link on your address send visitors on to any other website? open-redirect | Your live app | 10 | 4 of 4 | 0 of 6 | 4 of 4, 0 wrong |
| Does your file storage list its files to anyone? open-storage-listing | Your live app | 8 | 3 of 4 | 0 of 4 | 4 of 4, 0 wrong |
| Are private keys named so they ship to the browser? public-env-secrets | Your repository | 16 | 8 of 8 | 1 of 8 | 8 of 8, 0 wrong |
| Are keys or database dumps in the folder your site publishes? public-folder-secrets | Your repository | 8 | 3 of 4 | 0 of 4 | 3 of 4, 0 wrong |
| Does your app answer, and how fast? reachability | Your live app | 16 | 8 of 8 | 2 of 8 | 8 of 8, 2 wrong |
| Are keys or tokens written into your page’s links? secrets-in-urls | Your live app | 16 | 7 of 8 | 0 of 8 | 8 of 8, 0 wrong |
| Do your pages ask browsers to protect them? security-headers | Your live app | 16 | 8 of 8 | 1 of 8 | 8 of 8, 0 wrong |
| Do files from other websites load without a fingerprint? sri-missing | Your live app | 8 | 4 of 4 | 0 of 4 | 4 of 4, 0 wrong |
| Can someone claim one of your subdomains? subdomain-takeover | Your live app | 8 | 2 of 4 | 0 of 4 | 2 of 4, 0 wrong |
| Can people upload files without signing in? supabase-anon-uploads | Your repository | 8 | 4 of 4 | 0 of 4 | 4 of 4, 0 wrong |
| Can strangers sign up as someone else, or without an account? supabase-auth | Your live app | 20 | 10 of 10 | 0 of 10 | 10 of 10, 0 wrong |
| Can the browser read your users’ sign-in records? supabase-auth-users-exposed | Your repository | 8 | 4 of 4 | 0 of 4 | 4 of 4, 0 wrong |
| Do database views show rows your rules hide? supabase-definer-views | Your repository | 8 | 4 of 4 | 0 of 4 | 4 of 4, 0 wrong |
| Can anyone call a database function that skips your rules? supabase-exposed-functions | Your repository | 8 | 3 of 4 | 0 of 4 | 3 of 4, 0 wrong |
| Do your database rules trust a field users can edit? supabase-metadata-policies | Your repository | 8 | 4 of 4 | 0 of 4 | 4 of 4, 0 wrong |
| Do your database rules let anyone change other people’s data? supabase-policies | Your repository | 20 | 9 of 10 | 0 of 10 | 10 of 10, 0 wrong |
| Can your database tables be read without signing in? supabase-rls | Your live app | 16 | 8 of 8 | 0 of 8 | 8 of 8, 0 wrong |
| Does your file storage show strangers what it holds? supabase-storage | Your live app | 20 | 10 of 10 | 0 of 10 | 10 of 10, 0 wrong |
| Is infrastructure state with passwords in your repository? terraform-state | Your repository | 8 | 4 of 4 | 0 of 4 | 4 of 4, 0 wrong |
| Is the certificate valid, trusted and far from expiring? tls | Your live app | 16 | 8 of 8 | 0 of 8 | 8 of 8, 0 wrong |
| Do the libraries you install have known flaws? vulnerable-dependencies | Your repository | 20 | 10 of 10 | 0 of 10 | 10 of 10, 0 wrong |
| Do your pages load libraries with known flaws? vulnerable-libraries | Your live app | 20 | 9 of 10 | 0 of 10 | 10 of 10, 0 wrong |
Supabase and Firebase
Many apps built with AI tools keep their data in Supabase, and the protection of each table is a row-level security rule the app has to write. In 2025, an insufficient row-level security policy in apps generated by one AI builder was recorded as a vulnerability of its own. The builder disputes it, because each of its customers is responsible for their app’s data.
We have 9 Supabase checks and 2 for Firebase in this benchmark. On the live app, one asks each table the app reads for one row with the public key and records whether a row came back, never the row. In the repository, others read the migrations for tables without row-level security and for rules that let anyone change every row. Together they found 69 of 74 broken setups (93.2%) and called none of the 74 safe ones broken. The table check on the live app found 8 of 8; the migration check found 9 of 10.
That is the part of the benchmark we trust most. It is also where a founder can act at once, because a table that answers strangers is closed by a policy on that table. Of the 9 Supabase checks, two missed a case the first time, and one of those misses is kept on purpose, as the next section explains.
After the fixes
Every miss and every false alarm was studied. 16 of the 33 misses and 9 of the 11 false alarms led to a fix. Scored again, the same 702 cases give 333 of 350 found and 2 false alarms. That is what checks that have learned from their test look like, and it says nothing about apps they have not seen. It is why each round got a new set instead of another pass over an old one.
The release that shipped the first two sets’ checks said they found 54 of 54 with no false alarm. That was true of the version that shipped, after the fixes. The first time, those sets found 51 of 54 and raised 2 false alarms. We should have given both numbers then, and we give them now.
Each set, first and after the fixes it led to
The numbers
| Set | Found, first | Found, after fixes | Called broken, first | Called broken, after fixes |
|---|---|---|---|---|
| Set 1 set-1 | 25 of 27 | 27 of 27 | 1 of 27 | 0 of 27 |
| Set 2 set-2 | 26 of 27 | 27 of 27 | 1 of 27 | 0 of 27 |
| Set 3 set-3 | 42 of 44 | 44 of 44 | 6 of 44 | 2 of 44 |
| Set 4 set-4 | 36 of 36 | nothing to fix | 0 of 36 | nothing to fix |
| Set 5 modules-holdout | 43 of 48 | 47 of 48 | 2 of 48 | 0 of 48 |
| Set 6 set-5 | 42 of 44 | 44 of 44 | 1 of 44 | 0 of 44 |
| Set 7 modules-final | 46 of 48 | 48 of 48 | 0 of 48 | 0 of 48 |
| Set 8 round2-blind | 53 of 72 | 56 of 72 | 0 of 72 | 0 of 72 |
| Set 9 round3-blind | 4 of 4 | 4 of 4 | 0 of 6 | 0 of 6 |
17 cases are still missed, and two safe setups still get a note. We kept them on purpose, each with the source that decides it. The checks stay quiet on:
- a wildcard in the origins Next.js allows for Server Actions, which Next.js itself never matches (4 cases)
- GitHub Actions whose tags were restored after an incident, which the advisories call safe again (3 cases)
- SVG images through the Next.js image optimizer, which has sent them with a policy that stops scripts since version 12.3 (3 cases)
- subdomains pointing at services where taking over a name is not known to work, such as GitHub Pages (2 cases)
- a GraphQL server that only answers its schema to a POST, which our checks never send (1 case)
- a build that copies every variable into the browser code, which Next.js refuses to build (1 case)
- a database function that looks up one user by email, the usual way to send an invite (1 case)
- a version lookup that needs the owner’s OK, in a case written without it (1 case)
- a stand-in for a key that does not have the shape of a real one (1 case)
The two safe setups that still get a note sit behind a bot challenge page. Our check cannot see such an app, and it says so in the report instead of calling the app fine. The brief had asked for silence, and we think the note is right.
What we don’t know yet
Every case is an app we made up. The brief asked for the migrations, lockfiles and pages that apps built with Lovable, Bolt, v0, Cursor or Replit have. No real app’s data is in them, and real apps are messier. Our study of code checks showed how far a score on written cases can fall on real code. We expect less of a fall here, because a live page has far fewer ways to say the same thing than code does, but we have not measured it.
The cases and their labels come from inside Vallit, from writers who could not see the checks. No outside security expert has reviewed them. A check that scored every case right on eight cases has a 95% interval that reaches down to 51% for what it finds. Five of the nine reports were run on a working copy with changes not yet committed. For those, the SHA-256 of the checks’ files says what ran, and the commit only says where it started. Two cases were written without the owner’s OK that their checks need, so the check did not run on them; we count them as the run scored them.
Apps built with AI tools can only be measured when their owners ask, and that is the study we want to run next.
We publish the data behind this study. Every measured result on this page is computed from the benchmark’s raw reports and cases, whose SHA-256 it lists. Beside it are all 702 cases with their first and later answers and a readme that says what each file holds. The cases themselves and the checks are not public. If you research this problem and want to check our work more closely, write to info@vallit.net.
Citation
@techreport{vallit2026blindchecks,
title = {Right the first time? 51 checks on 702 cases they had never seen},
author = {{Vallit}},
institution = {Vallit},
year = {2026},
month = oct,
url = {https://www.vallit.net/research/right-the-first-time}
}