Detection

Right the first time?

An open-air observatory of monumental stone instruments at dawn: a stair-shaped sundial whose shadow falls across a curved stone scale, fog over the plain.

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

Seven of nine sets found nine in ten broken setups or more; the weakest found 74%. Each set scored once, in the order they were run, by checks frozen before it was written. Lines show 95% intervals.
The numbers
SetCasesBroken setups foundShare found (95% interval)Safe setups called brokenShare (95% interval)
Set 1 set-15425 of 2792.6% (76.6% to 97.9%)1 of 273.7% (0.7% to 18.3%)
Set 2 set-25426 of 2796.3% (81.7% to 99.3%)1 of 273.7% (0.7% to 18.3%)
Set 3 set-38842 of 4495.5% (84.9% to 98.7%)6 of 4413.6% (6.4% to 26.7%)
Set 4 set-47236 of 36100.0% (90.4% to 100.0%)0 of 360.0% (0.0% to 9.6%)
Set 5 modules-holdout9643 of 4889.6% (77.8% to 95.5%)2 of 484.2% (1.2% to 14.0%)
Set 6 set-58842 of 4495.5% (84.9% to 98.7%)1 of 442.3% (0.4% to 11.8%)
Set 7 modules-final9646 of 4895.8% (86.0% to 98.8%)0 of 480.0% (0.0% to 7.4%)
Set 8 round2-blind14453 of 7273.6% (62.4% to 82.4%)0 of 720.0% (0.0% to 5.1%)
Set 9 round3-blind104 of 4100.0% (51.0% to 100.0%)0 of 60.0% (0.0% to 39.0%)
All 9 sets702317 of 35090.6% (87.1% to 93.2%)11 of 3523.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 repository checks missed more; the database checks raised no false alarm. First scores pooled by where each check looks, and the Supabase and Firebase checks of both kinds. The numbers below list all 51 checks. Lines show 95% intervals.
The numbers
GroupCasesBroken setups foundShare found (95% interval)Safe setups called brokenShare (95% interval)
Your live app498231 of 24893.1% (89.3% to 95.7%)8 of 2503.2% (1.6% to 6.2%)
Your repository20486 of 10284.3% (76.0% to 90.1%)3 of 1022.9% (1.0% to 8.3%)
Supabase and Firebase, your live app7234 of 3694.4% (81.9% to 98.5%)0 of 360.0% (0.0% to 9.6%)
Supabase and Firebase, your repository7635 of 3892.1% (79.2% to 97.3%)0 of 380.0% (0.0% to 9.2%)
Supabase and Firebase, both14869 of 7493.2% (85.1% to 97.1%)0 of 740.0% (0.0% to 4.9%)
CheckLooks atCasesBroken setups foundSafe setups called brokenAfter fixes
Can a shared cache hand one visitor’s sign-in to another? cache-leakYour live app168 of 80 of 88 of 8, 0 wrong
Can a stranger run commands in your GitHub Actions? ci-injectionYour repository168 of 80 of 88 of 8, 0 wrong
Are secret keys written into your repository? committed-secretsYour repository2010 of 102 of 1010 of 10, 0 wrong
Do your workflows use an action release known to be malicious? compromised-actionsYour repository81 of 40 of 41 of 4, 0 wrong
Do your pages load scripts from hijacked domains? compromised-script-hostsYour live app167 of 80 of 88 of 8, 0 wrong
Can scripts or other sites read your session cookies? cookie-flagsYour live app168 of 81 of 88 of 8, 0 wrong
Does your API answer requests from any website? corsYour live app166 of 80 of 88 of 8, 0 wrong
Would your Content Security Policy stop an injected script? csp-weaknessYour live app168 of 80 of 88 of 8, 0 wrong
Do old subdomains point at services that are gone? dangling-subdomainsYour live app2010 of 100 of 1010 of 10, 0 wrong
Is your app running in development mode? dev-server-exposedYour live app168 of 81 of 88 of 8, 0 wrong
Is your domain registration close to expiring? domain-expiryYour live app2010 of 100 of 1010 of 10, 0 wrong
Can someone send email that claims to come from you? email-authYour live app209 of 100 of 1010 of 10, 0 wrong
Does your build copy every environment variable into the browser code? env-in-client-bundleYour repository83 of 40 of 43 of 4, 0 wrong
Are admin panels or status pages open to anyone? exposed-admin-toolsYour live app84 of 40 of 44 of 4, 0 wrong
Are backups, database exports or folder listings reachable? exposed-artifactsYour live app167 of 80 of 88 of 8, 0 wrong
Can files with passwords or keys be downloaded? exposed-credential-filesYour live app84 of 40 of 44 of 4, 0 wrong
Can anyone download files like .env or .git? exposed-filesYour live app168 of 81 of 88 of 8, 0 wrong
Are secret keys or passwords in what browsers download? exposed-keysYour live app168 of 81 of 88 of 8, 0 wrong
Can your Firebase database be read without signing in? firebase-open-dbYour live app166 of 80 of 88 of 8, 0 wrong
Do your Firebase rules let strangers read or change data? firebase-rulesYour repository167 of 80 of 88 of 8, 0 wrong
Does your app run a framework version with known flaws? framework-versionsYour live app82 of 40 of 43 of 4, 0 wrong
Does your GraphQL API describe itself to strangers? graphql-introspectionYour live app167 of 80 of 87 of 8, 0 wrong
Does your http address switch to https? https-redirectYour live app168 of 80 of 88 of 8, 0 wrong
Do forms send passwords unencrypted or in the address? insecure-formsYour live app167 of 80 of 88 of 8, 0 wrong
Do secure pages load anything over an insecure connection? mixed-contentYour live app167 of 81 of 88 of 8, 0 wrong
Can other websites trigger your app’s actions? next-action-originsYour repository80 of 40 of 40 of 4, 0 wrong
Can anyone use your app to fetch images from any website? next-image-proxyYour repository83 of 40 of 44 of 4, 0 wrong
Can SVG images you serve run scripts? next-svg-imagesYour repository81 of 40 of 41 of 4, 0 wrong
Are search engines told what they may list? noindexYour live app168 of 80 of 88 of 8, 0 wrong
Can a link on your address send visitors on to any other website? open-redirectYour live app104 of 40 of 64 of 4, 0 wrong
Does your file storage list its files to anyone? open-storage-listingYour live app83 of 40 of 44 of 4, 0 wrong
Are private keys named so they ship to the browser? public-env-secretsYour repository168 of 81 of 88 of 8, 0 wrong
Are keys or database dumps in the folder your site publishes? public-folder-secretsYour repository83 of 40 of 43 of 4, 0 wrong
Does your app answer, and how fast? reachabilityYour live app168 of 82 of 88 of 8, 2 wrong
Are keys or tokens written into your page’s links? secrets-in-urlsYour live app167 of 80 of 88 of 8, 0 wrong
Do your pages ask browsers to protect them? security-headersYour live app168 of 81 of 88 of 8, 0 wrong
Do files from other websites load without a fingerprint? sri-missingYour live app84 of 40 of 44 of 4, 0 wrong
Can someone claim one of your subdomains? subdomain-takeoverYour live app82 of 40 of 42 of 4, 0 wrong
Can people upload files without signing in? supabase-anon-uploadsYour repository84 of 40 of 44 of 4, 0 wrong
Can strangers sign up as someone else, or without an account? supabase-authYour live app2010 of 100 of 1010 of 10, 0 wrong
Can the browser read your users’ sign-in records? supabase-auth-users-exposedYour repository84 of 40 of 44 of 4, 0 wrong
Do database views show rows your rules hide? supabase-definer-viewsYour repository84 of 40 of 44 of 4, 0 wrong
Can anyone call a database function that skips your rules? supabase-exposed-functionsYour repository83 of 40 of 43 of 4, 0 wrong
Do your database rules trust a field users can edit? supabase-metadata-policiesYour repository84 of 40 of 44 of 4, 0 wrong
Do your database rules let anyone change other people’s data? supabase-policiesYour repository209 of 100 of 1010 of 10, 0 wrong
Can your database tables be read without signing in? supabase-rlsYour live app168 of 80 of 88 of 8, 0 wrong
Does your file storage show strangers what it holds? supabase-storageYour live app2010 of 100 of 1010 of 10, 0 wrong
Is infrastructure state with passwords in your repository? terraform-stateYour repository84 of 40 of 44 of 4, 0 wrong
Is the certificate valid, trusted and far from expiring? tlsYour live app168 of 80 of 88 of 8, 0 wrong
Do the libraries you install have known flaws? vulnerable-dependenciesYour repository2010 of 100 of 1010 of 10, 0 wrong
Do your pages load libraries with known flaws? vulnerable-librariesYour live app209 of 100 of 1010 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

Most of the gap closed; what stayed open was kept on purpose. The share of broken setups found and of alerts that were right, the first time and after fixes. A set with nothing to fix is left out of the chart and kept in the numbers.
The numbers
SetFound, firstFound, after fixesCalled broken, firstCalled broken, after fixes
Set 1 set-125 of 2727 of 271 of 270 of 27
Set 2 set-226 of 2727 of 271 of 270 of 27
Set 3 set-342 of 4444 of 446 of 442 of 44
Set 4 set-436 of 36nothing to fix0 of 36nothing to fix
Set 5 modules-holdout43 of 4847 of 482 of 480 of 48
Set 6 set-542 of 4444 of 441 of 440 of 44
Set 7 modules-final46 of 4848 of 480 of 480 of 48
Set 8 round2-blind53 of 7256 of 720 of 720 of 72
Set 9 round3-blind4 of 44 of 40 of 60 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}
}