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.

fix proven
  • check id: sample
  • started 9 Nov 2026, 16:55 UTC
  • report ready 16:57 UTC
  • lab run 36 s

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.

Tables rebuilt2

profiles and bookings, from 1 migration

Leaking1

profiles: readable by anyone with the public key

Lab proof3of 3

anon denied, stranger denied, owner OK

Exposed keys0

in the code and its full git history

Risk of a visible changeLow

the app only reads each user’s own profile

Fix written in36s

smallest model, first attempt

Timeline (UTC, 9 Nov 2026)

  1. Check started

    Repo submitted. The check waited for proof of ownership.

    16:55:42
  2. Ownership proven

    patchlabs-verify.txt with the one-time code was found on the default branch.

    16:56:15
  3. Lab run

    Database rebuilt from the migrations, test users seeded, read as three identities, fix written and proven.

    fix model run: 36 s
  4. Report ready

    This page, the fix file and the receipt.

    16:57:00

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.

CheckStatusDetails
Database rebuilt from migrationsran1 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 visitorranQueried every table as the anon role, which is what your public key gives anyone.
Read access: signed-in strangerranQueried every table as a signed-in test user who owns none of the rows.
Owner still worksranAfter the fix, the row’s owner read their own row.
Key scanranEvery file and the full git history, for AI, payment and database keys. None found.
Write access (insert, update, delete)not testedThis check tests who can read. The free RLS check flags risky write rules in the SQL.
Storage bucketsnot coveredNot part of this check. (This demo creates no buckets.)
Edge functionsnot coveredFunction code isn’t run or analysed.
Your live app and databaseneverBy 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

criticalPL-001readable by anyone

Anyone with the app’s public key can read every profile, including email and phone.

The rule that leaks

supabase/migrations/20260101000000_init.sql
 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)

terminal
# 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

browser console
const { data } = await supabase
  .from('profiles')
  .select('full_name, email, phone')
// returns every user's profile, not just yours

SQL, as the lab runs it

lab Postgres
-- anonymous visitor = the anon role
set local role anon;
select * from public.profiles;
-- 2 rows: both seeded test users

What came back

Rows the anonymous visitor read in the lab, redacted
iduser_idfull_nameemailphonecreated_at
••••••••-••••••••••••-••••••••• ••••••••••@•••••+•• •••• ••••••••-••-••
••••••••-••••••••••••-••••••••• ••••••••••@•••••+•• •••• ••••••••-••-••
The anonymous visitor read 2 of 2 rows and all 6 columns. Personal columns are marked. These are fake test users, redacted anyway: reports never print personal values.

Why it matters

  1. The public key isn’t a secret. src/supabase.ts builds the client from VITE_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.
  2. This rule leaves the lock open. USING (true) with no TO clause applies to every role, including anon. One request lists every user’s name, email and phone number.
  3. 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.
  4. 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.
src/Account.tsx (from the repo)
// 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

policy on public.profiles
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

supabase/migrations/20261109165700_patchlabs_leak_fix.sql
-- 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);
Download the fix (.sql) Demo: only the repo owner can commit

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

  1. Add the file to supabase/migrations/, keeping the timestamped name so it runs last.
  2. Review and merge it like any other change.
  3. 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

Before2 rowsLEAK
After0 rowsDENIED

Signed-in stranger

authenticated · owns no rows

Before2 rowsLEAK
After0 rowsDENIED

Owner

authenticated · owns one row

Beforechecked after the fix
After1 own rowOK

Full lab transcript

lab run · manra399/patchlabs-demo-app · 9 Nov 2026
$ 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
provenReal run · demo app, fake data · values redacted

Model attempts

AttemptModelTimeModel costLab proof
1Claude Haiku (smallest)36 s$0.1146passed

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.

lab: three-identity proof
-- 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 rows

06 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

  1. Rotate it with the provider first. Deleting it from the code doesn’t help: it stays in the git history.
  2. Check the provider’s usage logs for calls you didn’t make.
  3. Move it to an environment variable or server-side secret. Keys for AI or payment services should never reach the browser.
  4. 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 findingStatic check saysThe lab says
PL-001 criticalAnyone 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 mediumUPDATE 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.

Run the free check on this repo

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.

receipt body for this check
{
  "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

  1. Fetch GET https://api.patchlabs.dev/receipts/<check id>. It’s public by link, like the report.
  2. Fetch our public key from /.well-known/patchlabs-receipt-key on the API and check the key_id matches.
  3. Serialise receipt as JSON with sorted keys and no spaces, and verify the signature against it.
verify_receipt.py
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 fix20261109165700_patchlabs_leak_fix.sql
Download
Check datasample.json: summary, report, fix
Download
Report as Markdownpatchlabs-demo-app-report.md
Download
This page as PDFuses your browser’s print dialog

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.

Free for your first appNever touches your databasePrefer a person first? Email the founder