Sample proof report
manra399/patchlabs-demo-app
A real PatchLabs check of our own deliberately leaky demo app, run on 9 Nov 2026. The users are fake; every result on this page comes from that run.
This is a demo. The app and its public repo are ours, seeded with fake users so anyone can see a full report. Buttons that would change a real app are switched off. Run the same check on your app, free for your first app.
01 Summary
One leak found. The fix passed all three lab checks.
Anyone holding the app’s public key could read every user’s name, email and phone number. The fix limits reads to each user’s own profile. In the lab, the anonymous visitor and a signed-in stranger were denied, and the owner still read their own row.
profiles and bookings, from 1 migration
profiles: readable by anyone with the public key
anon denied, stranger denied, owner OK
in the code and its full git history
the app only reads each user’s own profile
smallest model, first attempt
Timeline (UTC, 9 Nov 2026)
Check started
Repo submitted. The check waited for proof of ownership.
Ownership proven
patchlabs-verify.txtwith the one-time code was found on the default branch.Lab run
Database rebuilt from the migrations, test users seeded, read as three identities, fix written and proven.
Report ready
This page, the fix file and the receipt.
02 Coverage
What this check looked at, and what it didn’t.
A result only covers the checks that ran. Here is the full list for this run.
| Check | Status | Details |
|---|---|---|
| Database rebuilt from migrations | ran | 1 file, supabase/migrations/20260101000000_init.sql, in a disposable Postgres with Supabase’s roles, auth functions and default grants. 2 tables in the public schema. |
| Read access: anonymous visitor | ran | Queried every table as the anon role, which is what your public key gives anyone. |
| Read access: signed-in stranger | ran | Queried every table as a signed-in test user who owns none of the rows. |
| Owner still works | ran | After the fix, the row’s owner read their own row. |
| Key scan | ran | Every file and the full git history, for AI, payment and database keys. None found. |
| Write access (insert, update, delete) | not tested | This check tests who can read. The free RLS check flags risky write rules in the SQL. |
| Storage buckets | not covered | Not part of this check. (This demo creates no buckets.) |
| Edge functions | not covered | Function code isn’t run or analysed. |
| Your live app and database | never | By design. A check works from the GitHub repo only and never connects to the live app or its database. Live posture, a separate opt-in, reads only the public home page of a verified domain. |
What “proven” means here. The proof covers the read access we tested, on the database these migrations build. Changes made only in the Supabase dashboard aren’t in the repo, so the lab can’t see them. It is not a guarantee that the whole app is secure.
03 The leak · finding 1 of 1
Anyone with the app’s public key can read every profile, including email and phone.
The rule that leaks
3 CREATE TABLE public.profiles (
4 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
5 user_id uuid REFERENCES auth.users (id) ON DELETE CASCADE,
6 full_name text,
7 email text,
8 phone text,
9 created_at timestamptz DEFAULT now()
10 );
11 ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
12 CREATE POLICY "Profiles are viewable by everyone" ON public.profiles FOR SELECT USING (true);
13 CREATE POLICY "Users can update own profile" ON public.profiles FOR UPDATE USING (auth.uid() = user_id);
The exploit
Any one of these returns every user’s row. Nothing here needs a password: the project URL and public key are in the app’s JavaScript.
From a browser (HTTP)
# URL and key come from the app's own JavaScript bundle
curl "https://<project>.supabase.co/rest/v1/profiles?select=full_name,email,phone" \
-H "apikey: <public anon key>"supabase-js
const { data } = await supabase
.from('profiles')
.select('full_name, email, phone')
// returns every user's profile, not just yoursSQL, as the lab runs it
-- anonymous visitor = the anon role
set local role anon;
select * from public.profiles;
-- 2 rows: both seeded test usersWhat came back
| id | user_id | full_name | phone | created_at | |
|---|---|---|---|---|---|
| ••••••••-•••• | ••••••••-•••• | ••••• ••••• | •••••@••••• | +•• •••• •••• | ••••-••-•• |
| ••••••••-•••• | ••••••••-•••• | ••••• ••••• | •••••@••••• | +•• •••• •••• | ••••-••-•• |
Why it matters
- The public key isn’t a secret.
src/supabase.tsbuilds the client fromVITE_SUPABASE_ANON_KEY, and the build puts it in the JavaScript every visitor downloads. That’s normal for Supabase. It means the access rules are the only lock. - This rule leaves the lock open.
USING (true)with noTOclause applies to every role, includinganon. One request lists every user’s name, email and phone number. - That can be a reportable breach. In the UK, certain personal data breaches must be reported to the ICO within 72 hours. Work out what a leak would cost you.
- Closing it won’t break the app. The app’s own code only ever reads the signed-in user’s profile, so owner-only access is what it already expects. That’s why this report has no questions for you.
// The signed-in user's own account page.
const { data } = await supabase.from('profiles')
.select('full_name, email, phone').eq('user_id', userId).single()
04 The fix
One new migration, two statements.
Written by the smallest Claude model on its first attempt. It replaces the open read rule with one that lets each user read only their own profile. Your existing migration stays as it is.
What changes
CREATE POLICY "Profiles are viewable by everyone" ON public.profiles FOR SELECT USING (true);
CREATE POLICY "Users can view own profile" ON public.profiles FOR SELECT USING (auth.uid() = user_id);
The migration file
-- Fix: Replace overly permissive "everyone can read" policy with owner-only access
-- The original policy "Profiles are viewable by everyone" allowed anonymous and any signed-in user
-- to read all columns (email, phone) from all profile rows.
-- Change: Only allow users to read their own profile (where user_id = auth.uid())
DROP POLICY "Profiles are viewable by everyone" ON public.profiles;
CREATE POLICY "Users can view own profile" ON public.profiles FOR SELECT USING (auth.uid() = user_id);
On your report, “Commit the fix on GitHub” opens GitHub’s new-file page with the migration filled in: review it and press “Commit changes”. Automatic pull requests need our GitHub App, which is coming.
Risk that something behaves differently: low
From the report: only anonymous and other-user access is removed, which the frontend never relied on. Users still read their own profiles as before.
How to apply it
- Add the file to
supabase/migrations/, keeping the timestamped name so it runs last. - Review and merge it like any other change.
- Deploy as usual. In Lovable or Supabase it applies on the next database push.
05 Lab proof
Tested as three people, before and after.
The lab rebuilt the database, seeded two fake users and read profiles as each identity. A fix only counts as proven when all three pass.
Anonymous visitor
role anon · your public key
Signed-in stranger
authenticated · owns no rows
Owner
authenticated · owns one row
Full lab transcript
$ patchlabs check manra399/patchlabs-demo-app [REBUILD] 1 migration → lab Postgres OK Supabase roles, auth, default grants [SEED] 2 test users, fake rows OK [PROBE] as anon (your public key) select * from public.profiles; → 2 rows readable LEAK email •••••@••••• phone +•• •••• •••• email •••••@••••• phone +•• •••• •••• [PROBE] as stranger → 2 rows LEAK [FIX] smallest model, attempt 1 - drop policy "Profiles are viewable by everyone" + create policy "Users can view own profile" + for select using (auth.uid() = user_id); [PROVE] anon → 0 rows DENIED stranger → 0 rows DENIED owner → 1 row, their own OK [DONE] lab proof passed · 36 s
Model attempts
| Attempt | Model | Time | Model cost | Lab proof |
|---|---|---|---|---|
| 1 | Claude Haiku (smallest) | 36 s | $0.1146 | passed |
We start with the smallest model and only try a larger one if the lab proof fails. Spend is capped per check. If no attempt passes, the report says a person is reviewing the fix instead of calling it proven.
Show the SQL behind the three checks
Simplified from what the lab runs. The identities are simulated inside the disposable database.
-- 1. anonymous visitor (anon key)
set local role anon;
select count(*) from public.profiles; -- expect 0
-- 2. signed-in stranger
set local role authenticated;
select set_config('request.jwt.claims', '{"sub":"<stranger>"}', true);
select count(*) from public.profiles; -- expect 0
-- 3. owner
select set_config('request.jwt.claims', '{"sub":"<owner>"}', true);
select count(*) from public.profiles; -- expect their own rows06 Your decision
Your engineer approves every change.
Nothing reaches the live app until someone on your side applies the fix. On a real report, members of the app’s workspace record their decision here, and it goes into the signed receipt.
Fix for profiles
Demo: switched off- Approve
- Records your sign-off, with your name and the time, in the receipt.
- Meant to be readable
- Marks the table as intended. We remember it for this app, so re-checks list it as “marked as intended” instead of alerting you again.
- Questions for you
- None for this check. When a fix depends on a business choice, such as whether sellers’ emails should be public, the questions appear here in plain English with a recommended answer.
07 Key scan
No exposed keys, in the code or its history.
We searched every file in the repo and every commit in its git history for AI, payment and database keys. A key deleted from the code is still in the history, so we read both.
0 keys found
The app reads its Supabase URL and public key from environment variables (src/supabase.ts), so nothing is committed. The public key is meant to ship to browsers; that’s why the access rules in section 03 matter.
If a key is found, the report tells you to
- Rotate it with the provider first. Deleting it from the code doesn’t help: it stays in the git history.
- Check the provider’s usage logs for calls you didn’t make.
- Move it to an environment variable or server-side secret. Keys for AI or payment services should never reach the browser.
- Rewrite the history only after rotating, if you want it gone from old commits too.
08 Static check
What reading the SQL alone would have said.
Our free RLS check reads the same migration as text. Here is its real output next to what the lab found.
| Static finding | Static check says | The lab says |
|---|---|---|
| PL-001 critical | Anyone with the public key can read personal data (email, phone), line 12. | Confirmed. The anonymous visitor read 2 of 2 rows. Fixed and proven above. |
| PL-004 medium | UPDATE policy without WITH CHECK, line 13. | Not a leak here. Without WITH CHECK, Postgres applies the USING rule to the changed row too, so a user can’t hand their profile to someone else. Adding WITH CHECK would only make the intent explicit. |
09 Receipt
A signed record anyone can check.
Every finished check gets a receipt signed with Ed25519. It fixes the result, the hash of the fix file and every decision, so anyone can confirm none of them changed after we issued it.
{
"answers": [],
"check_id": "sample",
"decisions": [],
"finished_at": "2026-11-09T16:57:00.735345+00:00",
"fix_sha256": "b88f6829a1186bda766d5b3578ccdb20988ab910c2de66e1bc42a04489afb071",
"lab_proof_passed": true,
"repo": "manra399/patchlabs-demo-app",
"status": "done",
"tables_leaking": ["profiles"]
}
This demo isn’t signed. The sample is served as a static file with the id “sample”, so there’s no signature to verify. A real check’s receipt has these same fields with its own id, plus signature, key_id and algorithm.
Verify a real receipt
- Fetch
GET https://api.patchlabs.dev/receipts/<check id>. It’s public by link, like the report. - Fetch our public key from
/.well-known/patchlabs-receipt-keyon the API and check thekey_idmatches. - Serialise
receiptas JSON with sorted keys and no spaces, and verify the signature against it.
import base64, json, urllib.request
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
API, CHECK = "https://api.patchlabs.dev", "<check id>"
r = json.load(urllib.request.urlopen(f"{API}/receipts/{CHECK}"))
k = json.load(urllib.request.urlopen(f"{API}/.well-known/patchlabs-receipt-key"))
assert r["key_id"] == k["key_id"], "signed with a different key"
msg = json.dumps(r["receipt"], sort_keys=True, separators=(",", ":")).encode()
pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(k["public_key"]))
pub.verify(base64.b64decode(r["signature"]), msg) # raises if anything changed
print("receipt is genuine:", r["receipt"]["status"], r["receipt"]["tables_leaking"])
10 Downloads
Take it with you.
The same report, as the live check page shows it: /check?id=sample.
Your turn
Get this report for your own app.
Paste your public repo. You’ll get the leaks, the fix and the proof on one page, usually within minutes.