AppScan
For Supabase and Firebase apps

Is your data readable with the public key?

A backend the browser talks to directly is a backend anyone can talk to directly. The anon key is meant to be public; whether the rows behind it are is decided by policies nobody can see from the outside - unless something tries.

01The situation

Sound familiar?

Row-level security is probably on

It was enabled on the tables you remember. The one added at midnight, with the policy still on the to-do list, answers every query.

The anon key ships by design

It is in the bundle because it has to be. That makes it the credential every visitor holds, and the one worth testing with.

A service-role key in a client file

The key that bypasses every policy, pasted into a component "for the admin page". It is now in the bundle too.

Nobody has tried it from outside

The app works for logged-in users. Whether it also works for nobody is a question the app never asks itself.

02What changes

A probe with your own key.

The browser pass records the requests your page makes to its data backend. The access-control probe replays a few of them - read-only, with the public key the page itself shipped - and reports whether the data came back without a session.

  • Read-only, bounded, on your say-so

    At most 6 replays, only of requests classified as safe reads, and only after you have verified the domain by DNS TXT, Well-known file, Meta tag. browser.network.data-readable-unauthenticated

  • The backend the browser reaches

    A data backend reachable from the page is recorded as a fact, and a public key that opens it is a finding of its own. browser.network.public-key-data-backend

  • The key that should never reach a browser

    A Supabase service-role token in public JavaScript or in a client file is found by its shape, and the invariant that private keys stay on the server is re-checked on every push. source.secrets.private-key-exposed-to-browser

  • One finding, two proofs

    When the repository is connected, the route that skips the check and the response that proves it are one finding, not two.

Seen at runtime
GET /api/orders?status=open
200 · 148 items · no auth header

The browser pass proves the data comes back without a session.

Found in source
api/orders/route.ts:14
export async function GET(req) {

Taint analysis proves the route has no guard before the query.

correlated
Criticalsource.authorization.unguarded-mutating-routeconfirmed live
The orders route answers anyone who asks

One finding, two proofs: the line that is missing the guard, and the response that proves it is missing in production.

03A week with it

What the first week looks like

  1. 01
    Scan the deployed site

    The report says which data backend the page talks to and whether a request came back without a session.

  2. 02
    Verify the domain

    A TXT record, a well-known file or a meta tag. The probe and the rate-limit burst run only on a domain you have proven is yours.

  3. 03
    Connect the repository

    The inventory finds the service-role key if it is there, and the routes that query without a tenant scope.

  4. 04
    Fix, then watch it stay fixed

    The next scan re-runs the probe. The finding resolves when the data stops coming back.

Where to start
Pro$29 / month

The free scan runs the probe once on one page. Pro runs it across the crawl on every scan, and reads the repository for the key that should not be there.

  • The free scan already asks the question, once
  • Up to twenty pages per scan, each with its own requests
  • The source rules for exposed keys and unguarded routes
  • Fix with AI as a draft pull request
Create a free account

Start on Free, which does not expire. Upgrade when it pays for itself.

Ask the question the app never asks.

One URL. If your data answers to nobody, you will know today.