Probe your live app: what its own public key gives away
Every visitor of a Supabase-backed app holds the anon key before the first pixel renders — it ships in the bundle by design, published to every browser that loads. The question that decides your posture is not whether the key is public but what it can reach, and whether anything stronger travelled alongside it into browser-reachable code.
This page walks the outside-in test RowShield runs against a deployed app: what gets fetched, which two findings it raises, how to read each result honestly, and what each fix requires. Nothing here needs repository access, an account, or any input beyond the URL visitors already have.
Rules that check this
- criticalTable readable with the anon key
ANON_TABLE_READABLE - criticalservice_role key shipped to the browser
SERVICE_ROLE_KEY_EXPOSED
What the probe fetches, and why that is enough
The probe fetches the pages at your URL, follows same-origin script tags and module preloads, and scans everything retrieved for Supabase credential shapes. Legacy JWTs are classified by decoding their payload role claim — deliberately without verifying the signature, because the question is what the key claims to be, not whether it would authenticate. Newer formats are recognised textually: sb_secret_ secrets, sb_publishable_ publishable keys, and sbp_ management tokens.
Finding one is SERVICE_ROLE_KEY_EXPOSED: a credential carrying BYPASSRLS present in browser-reachable code. Once a browser can fetch it, it is public, every policy in the project becomes decorative, and rotation — not quiet removal from the bundle — is the first move, because copies exist wherever the bundle travelled. The finding is rated critical and carries rotate-first guidance; the stored record keeps a fingerprint of the key, six characters and a length, never the value.
The second pass: reading as a stranger
With credentials inventoried, the probe turns to behaviour. It asks PostgREST which tables the public surface exposes, then issues a GET against each carrying only the anon key — the exact request any visitor's browser makes on the app's behalf:
curl -s "https://your-project.supabase.co/rest/v1/documents?select=id" -H "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.REDACTED"
Reading each possible reply honestly
Three replies are possible, and each means something different. Rows coming back is finding two, ANON_TABLE_READABLE: the table answered a stranger, and the finding names the table with its column count while retaining none of the values. An empty array proves nothing either way — filtered-out and genuinely empty are indistinguishable from outside, which is why the probe records the table as reachable rather than clean instead of manufacturing confidence. A permission error means policies engaged, which is the quiet answer you want.
A clean result still has edges worth knowing. It says nothing about write paths, because nobody is going to insert rows into your database to prove a point; the catalog side of a connected scan covers writes through MISSING_WITH_CHECK. And it describes the moment it ran — the value of repeating it on a schedule is precisely that deployments move.
Fixing what the probe finds
For an exposed privileged key: rotate it first in the dashboard, then remove it from client code and move whatever needed it behind a server route or edge function. Everything using the old key stops working when you rotate — that list of breakage is the inventory you could not produce otherwise, and redeploying without the key closes the hole.
For a readable table: enable RLS, force it, and add policies scoped to a real ownership column in one migration:
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY; ALTER TABLE public.documents FORCE ROW LEVEL SECURITY; CREATE POLICY "owners_read_own_documents" ON public.documents FOR SELECT TO authenticated USING (user_id = (SELECT auth.uid())); CREATE POLICY "owners_write_own_documents" ON public.documents FOR INSERT TO authenticated WITH CHECK (user_id = (SELECT auth.uid()));
Keeping the outside view watched
Scheduled probes repeat the whole sequence on your plan's cadence and diff against the previous run, so a deployment that introduces a privileged key raises a created alert and a fix that slips back raises a stronger regressed alert. Retried scans stay idempotent, alerts fire on transitions rather than on every run, and the probe itself executes on a Cloudflare-edge worker close to wherever your app is served.
The constraints never relax: requests are limited to GET, HEAD and OPTIONS; loopback, RFC1918, link-local, CGNAT and cloud-metadata addresses are refused before any socket opens, re-checked per redirect hop. RowShield is an independent product, unaffiliated with and not endorsed by Supabase. Paste your deployed URL at rowshield.dev/audit to see what a stranger sees.
Frequently asked
- Can probing damage or change my app?
- No. The probe issues GET, HEAD and OPTIONS requests only, and there is no code path that writes. Reads it makes are the same reads any visitor's browser already performs.
- Should I probe applications I do not own?
- No — the probe exists for projects you operate or are authorised to test. Its requests are harmless reads, but authorisation to monitor someone else's backend is their call, not yours or ours.
- My dashboard shows green, yet the probe found a readable table. Which is right?
- Both, about different things. The dashboard reports configuration at the moment you opened it; the probe exercises behaviour — what an unauthenticated request receives right now. When the two disagree, the behaviour is the fact that matters.
Check your project in about ten seconds
Paste a URL. No signup, no writes, nothing stored.
Run the free audit