What the scanner sends
- The scan fetches the page at the URL you paste, reads the script tags and preload links in it, and keeps up to 40 script URLs.
- It downloads the 12 largest application chunks of those, capped at 3 MB each.
- Every request carries the user agent
BreachProbe/1.0 (+https://breachprobe.thecompound.tech; security self-scan). The scan does not disguise itself as a browser. - A page request times out at 12 seconds. A script times out at 9.
- Redirects are resolved one hop at a time, up to 5 of them, and every hop is checked before it is followed. A public host that redirects to a private address is refused at the hop.
- A loopback, private, link-local or reserved address is refused before any request is made.
- Reading the app is a
GET. The scan sends aPOSTin two places, and both are described below: the signups that create the test accounts, and the Stripe webhook probe.
The shipped code
- Every JSON Web Token in the bundle is decoded and its role read. Only
role=service_roleis reported. The anon key is meant to ship, so it is discarded and publishing it is not a finding. - The current Supabase key format is matched on its own.
sb_secret_has no payload to decode, so the token rule above cannot see it. - Stripe, OpenAI, Anthropic, Google, AWS, SendGrid and GitHub keys are matched on their issuer’s own prefix.
- A server secret given a browser-exposed prefix is reported by variable name rather than by value:
NEXT_PUBLIC_,VITE_,REACT_APP_,PUBLIC_orEXPO_PUBLIC_in front of a name ending inSECRET,SERVICE_ROLE,PRIVATE,PASSWORD,API_KEYorTOKEN. - A matched credential is redacted before it is stored or shown. What the report carries is the first six characters and the last four.
The JWT signing secret
The anon key and the service_role key are both JSON Web Tokens. The signing secret is what signs them. Anyone holding it can mint a token with any role and any user id, which defeats every row-level security policy on the project.
It cannot be recognised the way a leaked service_role key can. It has no payload to decode. It is a bare 40 character string that looks like a build hash, a cache key or an asset digest, and reporting it on that shape would fire on those on every site scanned.
So it is proved. The app publishes the proof itself: its anon key is public, ships in the bundle, and is signed with exactly this secret.
- Candidates are collected in two passes: up to 64 from a named variable (
JWT_SECRET,SUPABASE_JWT_SECRET,jwtSecret,signingSecret), then up to 2000 bare strings of roughly the right shape. - Each candidate is used to recompute the HS256 signature of the app’s own anon key, and the result is compared in constant time. One HMAC over a 60 byte input takes microseconds, so casting wide costs nothing.
- A match is the key. A 40 character string that is not the key does not reproduce the signature.
- When there is an anon key to test against and no candidate verifies, the report says that. A check that is silent on success cannot be told apart from a check that never ran.
- When the bundle carries no project key there is nothing to prove against, and the check reports nothing rather than guessing.
signingInput = header + "." + payload // of the app's own public anon keycandidate = a string found in the shipped bundleleaked = HMAC_SHA256(candidate, signingInput) == signature
Patterns are not proofs
Five checks read the shipped JavaScript for a shape rather than probing behaviour: an admin decision made in the browser, a session token read out of localStorage, a record fetched by a sequential id, an /admin route referenced in client code, and a debug flag left on in production.
They are named separately here because the standard is different. A pattern reports what the code looks like. A probe reports what the app did. Both are on the report, and only one of them is a proof.
Response headers
- One request to the app, and the headers it answers with. 5 headers are checked.
- A Content-Security-Policy carrying
frame-ancestorssatisfies the clickjacking check, so an app that hardened it one way is not reported for not having done it the other way.
The Supabase REST sweep
- The key used is the app’s own anon key, already in the bundle. Nothing is used here that a visitor does not already have.
- The table list comes from two places: the OpenAPI document PostgREST publishes at the REST root, and the table names the app’s own bundle passes to
.from(). The second is how the sweep still finds real tables when a project has locked its OpenAPI root. - Each table gets one read,
select=*&limit=1, with an exact row count requested. - Rows coming back means row-level security is not deciding who can read that table. That is the finding.
- HTTP 200 with zero rows means a policy is filtering, and is not reported. PostgREST answers a filtered read with 200 and an empty array, so a status code on its own never means open.
- 401, 403 or 404 means the request was blocked, which is the safe outcome.
- The schema-enumeration finding is raised only when the OpenAPI root actually published a schema. Table names harvested from the bundle are visible to this scanner, not to the world, and do not count toward it.
Two signed-in users
This is the check a passive scan cannot run, because running it needs a session. A policy that exists and a policy that isolates are different things, and only the second one can be read off a database by asking it.
- 2 throwaway accounts are created through the app’s own public signup endpoint,
/auth/v1/signup. - A session comes back only when the project auto-confirms signups. When confirmation is required there is no session, the probe records that it did not run and why, and nothing is scored as though isolation had been tested and held.
- The first account owns zero rows. For every table carrying a per-user column, up to 30 of them, it asks how many rows that account can read:
select=*&limit=2with an exact count. - Zero rows is isolation holding. A blocked request is isolation holding.
- Any row that account can read belongs to somebody else. That is the cross-tenant leak, and it is a count, not an opinion.
- The probe reads counts. It never returns the values inside another user’s rows.
- The probe never writes.
rls.tssends onePOSTin the whole file and it is the signup. - Auto-confirmed signups are reported on their own, because they also let an attacker register working accounts at scale with any email.
The Stripe routes
- These run only when the shipped page loads Stripe.
- Up to 8 candidate webhook paths are tried. Each gets a
POSTcarrying an empty JSON object, so an endpoint that trusts its payload has no order id, no amount and no session reference to act on. - A real endpoint is told apart from the site’s catch-all by a control request to a random path in the same directory. A response matching that control is the catch-all and is dropped.
- A response whose body is HTML is the site’s own page and is dropped. A webhook handler answers with JSON or an empty body.
- Each real endpoint then gets a second
POSTcarrying a correctly shaped but fakestripe-signatureheader. An error status to both is the correct behaviour, and it is recorded as a pass rather than left silent. - The success page is loaded with no query string at all, and checked against a control path. A page that renders the same confirmation with nothing in the URL is trusting the URL.
What the report will not claim
- An app that could not be reached is not scored. The score and the grade are both empty, not 0 and F. Nothing was measured, so nothing is scored.
- A probe that could not run says so and says why. A probe that ran and found nothing says that too. Those are different facts, and a security report that prints one as the other is wrong in the direction that costs the reader most.
- A finding is not raised on shape where a proof is available. The signing secret check is the worked case.
- The free scan withholds locations, not verdicts. It carries every finding’s title and what the engine found, and drops the table names and the written fix. That split is structural: the free serialiser maps five fields, and the field holding the table names is not one of them.
- Nothing on this page is typed beside the engine. Every figure above is read out of the scanner’s source when the site is built, and every behaviour stated here is re-asserted against the file that implements it, so a check that changes stops the build rather than leaving this page describing the old one.
What gates the deploy
scripts/check-jwt-secret.mjs runs before the build, and a failure stops the deploy. It mints a fresh 40 character secret on every run, signs an anon key with it, and puts the pair through seven cases: the secret in a named variable, the secret as a bare string, the secret buried in a bundle of 800 unrelated constants of the same shape, two clean bundles, a 40 character string that is not the key, and a bundle with no project key to prove against.
It exists because the first version of that check could not reach what it tested. require is not defined in an ES module, the call threw, the surrounding catch swallowed it, and the check answered pass to every case including the leaked ones. A check that cannot reach the thing it tests still reads as coverage, which is the failure this product is built to be incapable of shipping.
The list of every issue type the engine can name, with its severity and whether it carries a written fix, is on every check. A finished report of the worked example is the sample report.