Sample, deliberately flawed demo app. This dossier was produced from a made-up app built to contain access problems. It is not a real customer and none of the data is real.Back to Halbon Preflight
Halbon Preflight · Evidence dossier

Invoicing demo (deliberately flawed Lovable-style app)

Access-control verification of the database and file-storage rules (Supabase row-level security), with a scan for secrets in browser code. Generated 2026-11-01 by verification-engine 0.1.0.

The short version

We signed in as made-up users from different customer organisations and tried to read and change each other's data and files in Invoicing demo (deliberately flawed Lovable-style app), and scanned the app's source for keys that ship to the browser. We found 20 access problems in 9 places (app source, clients, invoice_line_items, invoice-pdfs (file bucket), client-files (file bucket), client_notes, avatars (file bucket), profiles, invoices). Each one comes with an exact example your developer can replay, or the file and line where it sits.

20problems found
33live checks found no problem
0not checked (no result reached)
8tables checked
3file buckets checked

This dossier reports what automated checks found against the access rules you declared. It records what was tested and what was found; it does not vouch that an app has no problems.

Problems found, most serious first

  1. A Supabase service-role key is in code that ships to the browser.
    Anyone who opens your site can copy the key and read, change, or delete every row and file in your project, ignoring all access rules.
  2. A JWT signing secret is in code that ships to the browser.
    With the signing secret, anyone can mint login tokens for any user, including admin roles.
  3. A service secret key is in code that ships to the browser.
    Anyone can copy the key and use the service as you: charge cards, run up AI or cloud bills, or read your data there.
  4. A secret is read through a browser-exposed environment variable.
    Whatever value that setting holds at build time is visible to every visitor, even though it never appears in your source code.
  5. Anyone on the internet can read clients without an account.
    Your public app key is visible in the browser, so anyone can download this data directly, without using your app at all.
  6. Anyone on the internet can read invoice_line_items without an account.
    Your public app key is visible in the browser, so anyone can download this data directly, without using your app at all.
  7. Anyone on the internet can download files from the invoice-pdfs bucket.
    Files meant for one customer can be downloaded by anyone who has or guesses the link, without an account.
  8. Anyone on the internet can download files from the client-files bucket.
    Files meant for one customer can be downloaded by anyone who has or guesses the link, without an account.
  9. Anyone on the internet can write to invoice_line_items without an account.
    Spam, fake records, or deleted data, from anyone who copies the public key out of your web app.
  10. Anyone on the internet can write to client_notes without an account.
    Spam, fake records, or deleted data, from anyone who copies the public key out of your web app.
  11. Signed-in users can read other organisations' rows in clients.
    One customer can see another customer's data. Anyone who signs up for a free account could read data from every other account.
  12. Signed-in users can read other organisations' rows in invoice_line_items.
    One customer can see another customer's data. Anyone who signs up for a free account could read data from every other account.
  13. Signed-in users can read other customers' files in the invoice-pdfs bucket.
    Invoices, contracts, or documents uploaded by one customer can be downloaded by another. For a public bucket, anyone who has or guesses a file's link can download it without signing in.
  14. Signed-in users can read other customers' files in the client-files bucket.
    Invoices, contracts, or documents uploaded by one customer can be downloaded by another. For a public bucket, anyone who has or guesses a file's link can download it without signing in.
  15. Signed-in users can change, delete, or create rows in invoice_line_items that are not theirs.
    One customer can tamper with or destroy another customer's records, or plant records in their account.
  16. Signed-in users can change, delete, or create rows in client_notes that are not theirs.
    One customer can tamper with or destroy another customer's records, or plant records in their account.
  17. Signed-in users can change or delete other customers' files in the avatars bucket.
    One customer can replace another customer's files (for example swap a profile picture or an invoice PDF) or delete them.
  18. Users can change protected fields in profiles (organisation, owner, or role).
    A user can break out of their own organisation: plant data in another customer's account, or change the field your app uses to decide what they may see, and then read another customer's data.
  19. Users can change protected fields in invoices (organisation, owner, or role).
    A user can break out of their own organisation: plant data in another customer's account, or change the field your app uses to decide what they may see, and then read another customer's data.
  20. Users can change protected fields in invoice_line_items (organisation, owner, or role).
    A user can break out of their own organisation: plant data in another customer's account, or change the field your app uses to decide what they may see, and then read another customer's data.

How to read this dossier

Failed

Demonstrated. We made the problem happen and recorded the exact user, query, and replay number. This is the strongest kind of finding.

Tested

Tested (statistical). We ran 50 generated scenarios and none broke the rule. Evidence, not proof: scenarios we did not generate were not tried.

Lead (settings scan)

Heuristic. A scan of the database settings matched a known-risky pattern. A lead for review; the live checks show whether it is exploitable.

Found by code scan

File and line. A pattern scan of your app's source matched a known secret format in a file that ships to the browser. Shown with its location; the value is redacted.

Not checked

No result. The check ran but could not reach a result: for example, the code scan could not read every file, or its only match is a medium-severity one in a file that may or may not ship to the browser (listed, and held here until a person confirms where the file ends up). Treat it as not covered.

Scan: no match

Weak by design. No known-bad pattern matched. That never means there is no problem.

What we did not check

These are outside this audit. Problems here would not show up in this dossier:

  • Storage beyond the declared buckets' folder rules: signed-URL expiry, file size and type limits, image transformations, and buckets not declared in the claims file (the storage settings scan still covers every bucket and file rule in the migrations).
  • Secrets outside the scanned source: .env files (never opened, by design), the deployed site's live bundle, git history, and keys held in server-side settings.
  • Edge functions, server code, and API routes, including anything that uses the service-role key.
  • Client code behaviour (what your web or mobile app does with the data it receives).
  • Authentication configuration: sign-up settings, email confirmation, password rules, OAuth providers, JWT expiry.
  • Database functions called directly over the API (RPC), views, and SECURITY DEFINER functions, beyond their effect on the declared tables.
  • Tables not declared in the claims file (the settings scan still covers every table in the exposed schemas).
  • Realtime subscriptions, GraphQL, and rate limiting.

Scenario size: Each scenario has 2 to 3 organisations, 1 to 2 users per organisation (one membership each), and 1 to 2 rows per organisation in every declared table (one per user for tables keyed by user). Results only cover the rules declared in the claims file (samples/supabase-demo/access-claims.json).

Table by table

organisations

Tested

The rules you declared: Rows belong to an organisation through id. Signed-in users may read only rows of their own organisation, and may change nothing (changes go through your server). Visitors without an account may not read or write anything.

Checks on this table
ResultCheckWhat happenedStrength
Tested Customers only see their own data Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Users cannot change other customers' data Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Strangers cannot read this table Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Strangers cannot change this table Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)

members

Tested

The rules you declared: Rows belong to an organisation through org_id and to a user through user_id. Signed-in users may read only rows of their own organisation, and may change nothing (changes go through your server). Visitors without an account may not read or write anything. Protected fields users must never change: role, org_id.

Checks on this table
ResultCheckWhat happenedStrength
Tested Customers only see their own data Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Users cannot change other customers' data Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Strangers cannot read this table Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Strangers cannot change this table Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Users cannot move data or promote themselves Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)

profiles

1 failed

The rules you declared: Rows belong to an organisation through org_id and to a user through id. Signed-in users may read only rows of their own organisation, and may change only their own rows. Visitors without an account may not read or write anything. Protected fields users must never change: org_id.

Users cannot move data or promote themselves

Failed

Users can change protected fields in profiles (organisation, owner, or role).

What we checked

We signed in as a user and tried to change protected fields on rows they are allowed to edit: which organisation a row belongs to, who owns it, or a user's role.

What happened

Failed. user-1 (member of org-A) changed org_id on profiles #1 (org-A, owned by user-1) to org-B, and the database accepted it. After the change, user-1 can read 1 more row(s) of invoices belonging to other organisations.

Why it matters

A user can break out of their own organisation: plant data in another customer's account, or change the field your app uses to decide what they may see, and then read another customer's data.

How to fix it

Add a WITH CHECK to the update policy that keeps the protected field inside the user's own scope, or stop users updating that column at all (column-level grants, or a trigger that rejects the change). Never base access decisions on a column the user can edit.

Evidence strength: Demonstrated. Shown with a concrete, replayable example: the exact user, query, and replay number (seed) are recorded below.

The exact example (for your developer)
Acting as
user-1 (signed in, member of org-A)
Action
UPDATE org_id on profiles #1 (org-A, owned by user-1)
Query sent
update "public"."profiles" set "org_id" = $1 where "id"::text = $2
Parameters
  1. 10000000-0000-4000-8000-000000000002
  2. 20000000-0000-4000-8000-000000000001
What happened
1 row(s) affected: org_id changed from 10000000-0000-4000-8000-000000000001 to 10000000-0000-4000-8000-000000000002 (org-B)
Knock-on effect
after the change, user-1 can read 1 more row(s) of invoices belonging to other organisations
What should happen
0 rows affected (or refused by WITH CHECK)
Scenario
org-A = 10000000-0000-4000-8000-000000000001
org-B = 10000000-0000-4000-8000-000000000002
user-1 = 20000000-0000-4000-8000-000000000001 (org-A, member)
user-2 = 20000000-0000-4000-8000-000000000002 (org-B, member)
16 rows seeded in total
Replay
Seed 20260702. Re-run pnpm verify --rls samples/supabase-demo/supabase/migrations --claims samples/supabase-demo/access-claims.json --app-src samples/supabase-demo/app --seed 20260702 --runs 50 to reproduce this exact case.
Technical record
Claim
claim.rls.escalation.immutable-columns.profiles
profiles: A signed-in user cannot change a protected column (tenant, owner, or role) on a row they are otherwise allowed to update.
Evidence record
ev_claim_rls_escalation_immutable_columns_profiles
Content hash
sha256:110700a14a3e09a68e741054723adbbca302ece2e768f41a083509018d7db38a
Assurance
seeded-deterministic / statistical / sampled (repeatability / strength / scope)
Gate
Failed: 1 assurance-bearing violation(s) detected (property-based). Assurance measure 0.00 (bounded in 0 to 0.95, never certainty).
Notes
attempts: 0 denied, 1 allowed, 0 not applicable
the counterexample is fast-check's shrunk case; replay the whole run with the recorded seed, or this case alone with fast-check path 0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0
Other checks on this table
ResultCheckWhat happenedStrength
Tested Customers only see their own data Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Users cannot change other customers' data Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Strangers cannot read this table Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Strangers cannot change this table Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)

clients

2 failed

The rules you declared: Rows belong to an organisation through org_id. Signed-in users may read only rows of their own organisation, and may change only rows of their own organisation. Visitors without an account may not read or write anything. Protected fields users must never change: org_id.

Customers only see their own data

Failed

Signed-in users can read other organisations' rows in clients.

What we checked

We signed in as different users from different customer organisations and listed this table. Each user must only see rows belonging to their own organisation (or to themselves, where the table is personal).

What happened

Failed. user-1 (member of org-A) listed clients and saw 1 row(s) that are not theirs to see: clients #2 (org-B).

Why it matters

One customer can see another customer's data. Anyone who signs up for a free account could read data from every other account.

How to fix it

Replace the read policy's condition with one that ties the row to the signed-in user's own organisation, for example org_id in (select org_id from members where user_id = auth.uid()). Never use using (true) on customer data.

Evidence strength: Demonstrated. Shown with a concrete, replayable example: the exact user, query, and replay number (seed) are recorded below.

The exact example (for your developer)
Acting as
user-1 (signed in, member of org-A)
Action
SELECT
Query sent
select "id"::text as "pk_id", "org_id"::text as "__tenant" from "public"."clients"
Parameters
none
What happened
2 row(s) visible, 1 outside user-1's organisation: clients #2 (org-B)
What should happen
only rows of org-A
Scenario
org-A = 10000000-0000-4000-8000-000000000001
org-B = 10000000-0000-4000-8000-000000000002
user-1 = 20000000-0000-4000-8000-000000000001 (org-A, member)
user-2 = 20000000-0000-4000-8000-000000000002 (org-B, member)
16 rows seeded in total
Replay
Seed 20260702. Re-run pnpm verify --rls samples/supabase-demo/supabase/migrations --claims samples/supabase-demo/access-claims.json --app-src samples/supabase-demo/app --seed 20260702 --runs 50 to reproduce this exact case.
Technical record
Claim
claim.rls.read.tenant-isolation.clients
clients: A signed-in user never SELECTs a row outside their own read scope (another tenant's rows, or another user's rows for owner-scoped tables).
Evidence record
ev_claim_rls_read_tenant_isolation_clients
Content hash
sha256:7c7c8dc2765e12d08d1460b7ddc6978d2e7ec6c1ac95071df8a7126f9e89ef38
Assurance
seeded-deterministic / statistical / sampled (repeatability / strength / scope)
Gate
Failed: 1 assurance-bearing violation(s) detected (property-based). Assurance measure 0.00 (bounded in 0 to 0.95, never certainty).
Notes
attempts: 0 denied, 1 allowed, 0 not applicable
the counterexample is fast-check's shrunk case; replay the whole run with the recorded seed, or this case alone with fast-check path 0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0

Strangers cannot read this table

Failed

Anyone on the internet can read clients without an account.

What we checked

We acted as a visitor who is not signed in (using only the public key that ships inside your web app) and tried to list this table.

What happened

Failed. A visitor who is not signed in listed clients and saw 2 of 2 row(s).

Why it matters

Your public app key is visible in the browser, so anyone can download this data directly, without using your app at all.

How to fix it

Enable row-level security on the table and restrict read policies to to authenticated with a condition tied to the user or organisation.

Evidence strength: Demonstrated. Shown with a concrete, replayable example: the exact user, query, and replay number (seed) are recorded below.

The exact example (for your developer)
Acting as
an anonymous visitor (anon key, not signed in)
Action
SELECT
Query sent
select count(*)::int as n from "public"."clients"
Parameters
none
What happened
2 of 2 row(s) visible without signing in
What should happen
0 rows (or permission denied)
Scenario
org-A = 10000000-0000-4000-8000-000000000001
org-B = 10000000-0000-4000-8000-000000000002
user-1 = 20000000-0000-4000-8000-000000000001 (org-A, member)
user-2 = 20000000-0000-4000-8000-000000000002 (org-B, member)
16 rows seeded in total
Replay
Seed 20260702. Re-run pnpm verify --rls samples/supabase-demo/supabase/migrations --claims samples/supabase-demo/access-claims.json --app-src samples/supabase-demo/app --seed 20260702 --runs 50 to reproduce this exact case.
Technical record
Claim
claim.rls.anon.read-denied.clients
clients: A visitor who is not signed in (the anon role) cannot SELECT any row.
Evidence record
ev_claim_rls_anon_read_denied_clients
Content hash
sha256:70e9a974b991298722ce2869ecd405f443b5eec8e8cc25ae672227e3264a7351
Assurance
seeded-deterministic / statistical / sampled (repeatability / strength / scope)
Gate
Failed: 1 assurance-bearing violation(s) detected (property-based). Assurance measure 0.00 (bounded in 0 to 0.95, never certainty).
Notes
attempts: 0 denied, 1 allowed, 0 not applicable
the counterexample is fast-check's shrunk case; replay the whole run with the recorded seed, or this case alone with fast-check path 0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0
Other checks on this table
ResultCheckWhat happenedStrength
Tested Users cannot change other customers' data Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Strangers cannot change this table Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Users cannot move data or promote themselves Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)

projects

Tested

The rules you declared: Rows belong to an organisation through org_id and to a user through created_by. Signed-in users may read only rows of their own organisation, and may change only rows of their own organisation. Visitors without an account may not read or write anything. Protected fields users must never change: org_id.

Checks on this table
ResultCheckWhat happenedStrength
Tested Customers only see their own data Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Users cannot change other customers' data Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Strangers cannot read this table Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Strangers cannot change this table Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Users cannot move data or promote themselves Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)

invoices

1 failed

The rules you declared: Rows belong to an organisation through org_id and to a user through created_by. Signed-in users may read only rows of their own organisation, and may change only their own rows. Visitors without an account may not read or write anything. Protected fields users must never change: org_id, created_by.

Users cannot move data or promote themselves

Failed

Users can change protected fields in invoices (organisation, owner, or role).

What we checked

We signed in as a user and tried to change protected fields on rows they are allowed to edit: which organisation a row belongs to, who owns it, or a user's role.

What happened

Failed. user-1 (member of org-A) changed org_id on invoices #1 (org-A, owned by user-1) to org-B, and the database accepted it.

Why it matters

A user can break out of their own organisation: plant data in another customer's account, or change the field your app uses to decide what they may see, and then read another customer's data.

How to fix it

Add a WITH CHECK to the update policy that keeps the protected field inside the user's own scope, or stop users updating that column at all (column-level grants, or a trigger that rejects the change). Never base access decisions on a column the user can edit.

Evidence strength: Demonstrated. Shown with a concrete, replayable example: the exact user, query, and replay number (seed) are recorded below.

The exact example (for your developer)
Acting as
user-1 (signed in, member of org-A)
Action
UPDATE org_id on invoices #1 (org-A, owned by user-1)
Query sent
update "public"."invoices" set "org_id" = $1 where "id"::text = $2
Parameters
  1. 10000000-0000-4000-8000-000000000002
  2. 30000004-0000-4000-8000-000000000001
What happened
1 row(s) affected: org_id changed from 10000000-0000-4000-8000-000000000001 to 10000000-0000-4000-8000-000000000002 (org-B)
What should happen
0 rows affected (or refused by WITH CHECK)
Scenario
org-A = 10000000-0000-4000-8000-000000000001
org-B = 10000000-0000-4000-8000-000000000002
user-1 = 20000000-0000-4000-8000-000000000001 (org-A, member)
user-2 = 20000000-0000-4000-8000-000000000002 (org-B, member)
16 rows seeded in total
Replay
Seed 20260702. Re-run pnpm verify --rls samples/supabase-demo/supabase/migrations --claims samples/supabase-demo/access-claims.json --app-src samples/supabase-demo/app --seed 20260702 --runs 50 to reproduce this exact case.
Technical record
Claim
claim.rls.escalation.immutable-columns.invoices
invoices: A signed-in user cannot change a protected column (tenant, owner, or role) on a row they are otherwise allowed to update.
Evidence record
ev_claim_rls_escalation_immutable_columns_invoices
Content hash
sha256:0cdba047b433ba0f8010ae1226f9d020c7fdf2cac9e486f59da9af7adbfcaff8
Assurance
seeded-deterministic / statistical / sampled (repeatability / strength / scope)
Gate
Failed: 1 assurance-bearing violation(s) detected (property-based). Assurance measure 0.00 (bounded in 0 to 0.95, never certainty).
Notes
attempts: 2 denied, 1 allowed, 0 not applicable
the counterexample is fast-check's shrunk case; replay the whole run with the recorded seed, or this case alone with fast-check path 2:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0
Other checks on this table
ResultCheckWhat happenedStrength
Tested Customers only see their own data Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Users cannot change other customers' data Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Strangers cannot read this table Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Strangers cannot change this table Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)

invoice_line_items

5 failed

The rules you declared: Rows belong to an organisation through org_id. Signed-in users may read only rows of their own organisation, and may change only rows of their own organisation. Visitors without an account may not read or write anything. Protected fields users must never change: org_id.

Customers only see their own data

Failed

Signed-in users can read other organisations' rows in invoice_line_items.

What we checked

We signed in as different users from different customer organisations and listed this table. Each user must only see rows belonging to their own organisation (or to themselves, where the table is personal).

What happened

Failed. user-1 (member of org-A) listed invoice_line_items and saw 1 row(s) that are not theirs to see: invoice_line_items #2 (org-B).

Why it matters

One customer can see another customer's data. Anyone who signs up for a free account could read data from every other account.

How to fix it

Replace the read policy's condition with one that ties the row to the signed-in user's own organisation, for example org_id in (select org_id from members where user_id = auth.uid()). Never use using (true) on customer data.

Evidence strength: Demonstrated. Shown with a concrete, replayable example: the exact user, query, and replay number (seed) are recorded below.

The exact example (for your developer)
Acting as
user-1 (signed in, member of org-A)
Action
SELECT
Query sent
select "id"::text as "pk_id", "org_id"::text as "__tenant" from "public"."invoice_line_items"
Parameters
none
What happened
2 row(s) visible, 1 outside user-1's organisation: invoice_line_items #2 (org-B)
What should happen
only rows of org-A
Scenario
org-A = 10000000-0000-4000-8000-000000000001
org-B = 10000000-0000-4000-8000-000000000002
user-1 = 20000000-0000-4000-8000-000000000001 (org-A, member)
user-2 = 20000000-0000-4000-8000-000000000002 (org-B, member)
16 rows seeded in total
Replay
Seed 20260702. Re-run pnpm verify --rls samples/supabase-demo/supabase/migrations --claims samples/supabase-demo/access-claims.json --app-src samples/supabase-demo/app --seed 20260702 --runs 50 to reproduce this exact case.
Technical record
Claim
claim.rls.read.tenant-isolation.invoice_line_items
invoice_line_items: A signed-in user never SELECTs a row outside their own read scope (another tenant's rows, or another user's rows for owner-scoped tables).
Evidence record
ev_claim_rls_read_tenant_isolation_invoice_line_items
Content hash
sha256:20b0bc0beab22e47b6b304999970b6a5688e0b5360e214d69e89e1d376bd37d7
Assurance
seeded-deterministic / statistical / sampled (repeatability / strength / scope)
Gate
Failed: 1 assurance-bearing violation(s) detected (property-based). Assurance measure 0.00 (bounded in 0 to 0.95, never certainty).
Notes
attempts: 0 denied, 1 allowed, 0 not applicable
the counterexample is fast-check's shrunk case; replay the whole run with the recorded seed, or this case alone with fast-check path 0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0

Users cannot change other customers' data

Failed

Signed-in users can change, delete, or create rows in invoice_line_items that are not theirs.

What we checked

We signed in as one user and tried to edit, delete, and create rows that belong to another organisation or another user.

What happened

Failed. user-1 (member of org-A) edited invoice_line_items #2 (org-B), and the database accepted it.

Why it matters

One customer can tamper with or destroy another customer's records, or plant records in their account.

How to fix it

Give each write policy (insert, update, delete) a condition that ties the row to the signed-in user's organisation or user id, in both USING and WITH CHECK.

Evidence strength: Demonstrated. Shown with a concrete, replayable example: the exact user, query, and replay number (seed) are recorded below.

The exact example (for your developer)
Acting as
user-1 (signed in, member of org-A)
Action
UPDATE on invoice_line_items #2 (org-B)
Query sent
update "public"."invoice_line_items" set "org_id" = "org_id" where "id"::text = $1
Parameters
  1. 30000005-0000-4000-8000-000000000002
What happened
1 row(s) affected
What should happen
0 rows affected (or permission denied)
Scenario
org-A = 10000000-0000-4000-8000-000000000001
org-B = 10000000-0000-4000-8000-000000000002
user-1 = 20000000-0000-4000-8000-000000000001 (org-A, member)
user-2 = 20000000-0000-4000-8000-000000000002 (org-B, member)
16 rows seeded in total
Replay
Seed 20260702. Re-run pnpm verify --rls samples/supabase-demo/supabase/migrations --claims samples/supabase-demo/access-claims.json --app-src samples/supabase-demo/app --seed 20260702 --runs 50 to reproduce this exact case.
Technical record
Claim
claim.rls.write.cross-scope.invoice_line_items
invoice_line_items: A signed-in user cannot UPDATE, DELETE, or INSERT a row outside their own write scope (another tenant, or another user for owner-scoped tables).
Evidence record
ev_claim_rls_write_cross_scope_invoice_line_items
Content hash
sha256:85e2b07cfd1a2e3416255bbbc7411bdfdf972b9e15aaf2754a0b59a9aeba8b22
Assurance
seeded-deterministic / statistical / sampled (repeatability / strength / scope)
Gate
Failed: 1 assurance-bearing violation(s) detected (property-based). Assurance measure 0.00 (bounded in 0 to 0.95, never certainty).
Notes
attempts: 0 denied, 1 allowed, 0 not applicable
the counterexample is fast-check's shrunk case; replay the whole run with the recorded seed, or this case alone with fast-check path 0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0

Strangers cannot read this table

Failed

Anyone on the internet can read invoice_line_items without an account.

What we checked

We acted as a visitor who is not signed in (using only the public key that ships inside your web app) and tried to list this table.

What happened

Failed. A visitor who is not signed in listed invoice_line_items and saw 2 of 2 row(s).

Why it matters

Your public app key is visible in the browser, so anyone can download this data directly, without using your app at all.

How to fix it

Enable row-level security on the table and restrict read policies to to authenticated with a condition tied to the user or organisation.

Evidence strength: Demonstrated. Shown with a concrete, replayable example: the exact user, query, and replay number (seed) are recorded below.

The exact example (for your developer)
Acting as
an anonymous visitor (anon key, not signed in)
Action
SELECT
Query sent
select count(*)::int as n from "public"."invoice_line_items"
Parameters
none
What happened
2 of 2 row(s) visible without signing in
What should happen
0 rows (or permission denied)
Scenario
org-A = 10000000-0000-4000-8000-000000000001
org-B = 10000000-0000-4000-8000-000000000002
user-1 = 20000000-0000-4000-8000-000000000001 (org-A, member)
user-2 = 20000000-0000-4000-8000-000000000002 (org-B, member)
16 rows seeded in total
Replay
Seed 20260702. Re-run pnpm verify --rls samples/supabase-demo/supabase/migrations --claims samples/supabase-demo/access-claims.json --app-src samples/supabase-demo/app --seed 20260702 --runs 50 to reproduce this exact case.
Technical record
Claim
claim.rls.anon.read-denied.invoice_line_items
invoice_line_items: A visitor who is not signed in (the anon role) cannot SELECT any row.
Evidence record
ev_claim_rls_anon_read_denied_invoice_line_items
Content hash
sha256:93fdb8c09f52b33e911506b60bb5c320d4484bfea907947f24ca6545bffed0bf
Assurance
seeded-deterministic / statistical / sampled (repeatability / strength / scope)
Gate
Failed: 1 assurance-bearing violation(s) detected (property-based). Assurance measure 0.00 (bounded in 0 to 0.95, never certainty).
Notes
attempts: 0 denied, 1 allowed, 0 not applicable
the counterexample is fast-check's shrunk case; replay the whole run with the recorded seed, or this case alone with fast-check path 0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0

Strangers cannot change this table

Failed

Anyone on the internet can write to invoice_line_items without an account.

What we checked

We acted as a visitor who is not signed in and tried to create, edit, and delete rows.

What happened

Failed. A visitor who is not signed in edited invoice_line_items #1 (org-A), and the database accepted it.

Why it matters

Spam, fake records, or deleted data, from anyone who copies the public key out of your web app.

How to fix it

Remove write policies that apply to everyone (no to clause, or with check (true)), and add to authenticated with a condition tied to the user or organisation.

Evidence strength: Demonstrated. Shown with a concrete, replayable example: the exact user, query, and replay number (seed) are recorded below.

The exact example (for your developer)
Acting as
an anonymous visitor (anon key, not signed in)
Action
UPDATE on invoice_line_items #1 (org-A)
Query sent
update "public"."invoice_line_items" set "org_id" = "org_id" where "id"::text = $1
Parameters
  1. 30000005-0000-4000-8000-000000000001
What happened
1 row(s) affected
What should happen
0 rows affected (or permission denied)
Scenario
org-A = 10000000-0000-4000-8000-000000000001
org-B = 10000000-0000-4000-8000-000000000002
user-1 = 20000000-0000-4000-8000-000000000001 (org-A, member)
user-2 = 20000000-0000-4000-8000-000000000002 (org-B, member)
16 rows seeded in total
Replay
Seed 20260702. Re-run pnpm verify --rls samples/supabase-demo/supabase/migrations --claims samples/supabase-demo/access-claims.json --app-src samples/supabase-demo/app --seed 20260702 --runs 50 to reproduce this exact case.
Technical record
Claim
claim.rls.anon.write-denied.invoice_line_items
invoice_line_items: A visitor who is not signed in (the anon role) cannot UPDATE, DELETE, or INSERT.
Evidence record
ev_claim_rls_anon_write_denied_invoice_line_items
Content hash
sha256:d93e5ef332b6a5f76e3a8b764e726da7d70b62d202a1627bf88325d12de4c7fb
Assurance
seeded-deterministic / statistical / sampled (repeatability / strength / scope)
Gate
Failed: 1 assurance-bearing violation(s) detected (property-based). Assurance measure 0.00 (bounded in 0 to 0.95, never certainty).
Notes
attempts: 0 denied, 1 allowed, 0 not applicable
the counterexample is fast-check's shrunk case; replay the whole run with the recorded seed, or this case alone with fast-check path 0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0

Users cannot move data or promote themselves

Failed

Users can change protected fields in invoice_line_items (organisation, owner, or role).

What we checked

We signed in as a user and tried to change protected fields on rows they are allowed to edit: which organisation a row belongs to, who owns it, or a user's role.

What happened

Failed. user-1 (member of org-A) changed org_id on invoice_line_items #1 (org-A) to org-B, and the database accepted it.

Why it matters

A user can break out of their own organisation: plant data in another customer's account, or change the field your app uses to decide what they may see, and then read another customer's data.

How to fix it

Add a WITH CHECK to the update policy that keeps the protected field inside the user's own scope, or stop users updating that column at all (column-level grants, or a trigger that rejects the change). Never base access decisions on a column the user can edit.

Evidence strength: Demonstrated. Shown with a concrete, replayable example: the exact user, query, and replay number (seed) are recorded below.

The exact example (for your developer)
Acting as
user-1 (signed in, member of org-A)
Action
UPDATE org_id on invoice_line_items #1 (org-A)
Query sent
update "public"."invoice_line_items" set "org_id" = $1 where "id"::text = $2
Parameters
  1. 10000000-0000-4000-8000-000000000002
  2. 30000005-0000-4000-8000-000000000001
What happened
1 row(s) affected: org_id changed from 10000000-0000-4000-8000-000000000001 to 10000000-0000-4000-8000-000000000002 (org-B)
What should happen
0 rows affected (or refused by WITH CHECK)
Scenario
org-A = 10000000-0000-4000-8000-000000000001
org-B = 10000000-0000-4000-8000-000000000002
user-1 = 20000000-0000-4000-8000-000000000001 (org-A, member)
user-2 = 20000000-0000-4000-8000-000000000002 (org-B, member)
16 rows seeded in total
Replay
Seed 20260702. Re-run pnpm verify --rls samples/supabase-demo/supabase/migrations --claims samples/supabase-demo/access-claims.json --app-src samples/supabase-demo/app --seed 20260702 --runs 50 to reproduce this exact case.
Technical record
Claim
claim.rls.escalation.immutable-columns.invoice_line_items
invoice_line_items: A signed-in user cannot change a protected column (tenant, owner, or role) on a row they are otherwise allowed to update.
Evidence record
ev_claim_rls_escalation_immutable_columns_invoice_line_items
Content hash
sha256:892c4767f5a77c307f452f6311d19aa431b5ba30970c85a16784933f8726b7e2
Assurance
seeded-deterministic / statistical / sampled (repeatability / strength / scope)
Gate
Failed: 1 assurance-bearing violation(s) detected (property-based). Assurance measure 0.00 (bounded in 0 to 0.95, never certainty).
Notes
attempts: 0 denied, 1 allowed, 0 not applicable
the counterexample is fast-check's shrunk case; replay the whole run with the recorded seed, or this case alone with fast-check path 0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0

client_notes

2 failed

The rules you declared: Rows belong to an organisation through org_id. Signed-in users may read only rows of their own organisation, and may change only rows of their own organisation. Visitors without an account may not read or write anything. Protected fields users must never change: org_id.

Users cannot change other customers' data

Failed

Signed-in users can change, delete, or create rows in client_notes that are not theirs.

What we checked

We signed in as one user and tried to edit, delete, and create rows that belong to another organisation or another user.

What happened

Failed. user-1 (member of org-A) created a new client_notes row in org-B, and the database accepted it.

Why it matters

One customer can tamper with or destroy another customer's records, or plant records in their account.

How to fix it

Give each write policy (insert, update, delete) a condition that ties the row to the signed-in user's organisation or user id, in both USING and WITH CHECK.

Evidence strength: Demonstrated. Shown with a concrete, replayable example: the exact user, query, and replay number (seed) are recorded below.

The exact example (for your developer)
Acting as
user-1 (signed in, member of org-A)
Action
INSERT on a new row in org-B
Query sent
insert into "public"."client_notes" ("id", "org_id", "client_id", "body", "author_id") values ($1, $2, $3, $4, $5)
Parameters
  1. 50000000-0000-4000-8000-000000000001
  2. 10000000-0000-4000-8000-000000000002
  3. 30000002-0000-4000-8000-000000000002
  4. client_notes-body-901
  5. 20000000-0000-4000-8000-000000000002
What happened
insert succeeded
What should happen
insert refused (row violates row-level security)
Scenario
org-A = 10000000-0000-4000-8000-000000000001
org-B = 10000000-0000-4000-8000-000000000002
user-1 = 20000000-0000-4000-8000-000000000001 (org-A, member)
user-2 = 20000000-0000-4000-8000-000000000002 (org-B, member)
16 rows seeded in total
Replay
Seed 20260702. Re-run pnpm verify --rls samples/supabase-demo/supabase/migrations --claims samples/supabase-demo/access-claims.json --app-src samples/supabase-demo/app --seed 20260702 --runs 50 to reproduce this exact case.
Technical record
Claim
claim.rls.write.cross-scope.client_notes
client_notes: A signed-in user cannot UPDATE, DELETE, or INSERT a row outside their own write scope (another tenant, or another user for owner-scoped tables).
Evidence record
ev_claim_rls_write_cross_scope_client_notes
Content hash
sha256:5bc1226774aecebdb578d72f5a6db27c97fef93ef575c9d36d355232e57af7cf
Assurance
seeded-deterministic / statistical / sampled (repeatability / strength / scope)
Gate
Failed: 1 assurance-bearing violation(s) detected (property-based). Assurance measure 0.00 (bounded in 0 to 0.95, never certainty).
Notes
attempts: 0 denied, 1 allowed, 0 not applicable
the counterexample is fast-check's shrunk case; replay the whole run with the recorded seed, or this case alone with fast-check path 0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:1:1

Strangers cannot change this table

Failed

Anyone on the internet can write to client_notes without an account.

What we checked

We acted as a visitor who is not signed in and tried to create, edit, and delete rows.

What happened

Failed. A visitor who is not signed in created a new client_notes row in org-A, and the database accepted it.

Why it matters

Spam, fake records, or deleted data, from anyone who copies the public key out of your web app.

How to fix it

Remove write policies that apply to everyone (no to clause, or with check (true)), and add to authenticated with a condition tied to the user or organisation.

Evidence strength: Demonstrated. Shown with a concrete, replayable example: the exact user, query, and replay number (seed) are recorded below.

The exact example (for your developer)
Acting as
an anonymous visitor (anon key, not signed in)
Action
INSERT on a new row in org-A
Query sent
insert into "public"."client_notes" ("id", "org_id", "client_id", "body", "author_id") values ($1, $2, $3, $4, $5)
Parameters
  1. 50000000-0000-4000-8000-000000000001
  2. 10000000-0000-4000-8000-000000000001
  3. 30000002-0000-4000-8000-000000000001
  4. client_notes-body-901
  5. 20000000-0000-4000-8000-000000000001
What happened
insert succeeded
What should happen
insert refused (row violates row-level security)
Scenario
org-A = 10000000-0000-4000-8000-000000000001
org-B = 10000000-0000-4000-8000-000000000002
user-1 = 20000000-0000-4000-8000-000000000001 (org-A, member)
user-2 = 20000000-0000-4000-8000-000000000002 (org-B, member)
16 rows seeded in total
Replay
Seed 20260702. Re-run pnpm verify --rls samples/supabase-demo/supabase/migrations --claims samples/supabase-demo/access-claims.json --app-src samples/supabase-demo/app --seed 20260702 --runs 50 to reproduce this exact case.
Technical record
Claim
claim.rls.anon.write-denied.client_notes
client_notes: A visitor who is not signed in (the anon role) cannot UPDATE, DELETE, or INSERT.
Evidence record
ev_claim_rls_anon_write_denied_client_notes
Content hash
sha256:e96123d07265a4926283ab6625e54cfdcc359ca3e279e836fded6253be7ad343
Assurance
seeded-deterministic / statistical / sampled (repeatability / strength / scope)
Gate
Failed: 1 assurance-bearing violation(s) detected (property-based). Assurance measure 0.00 (bounded in 0 to 0.95, never certainty).
Notes
attempts: 0 denied, 1 allowed, 0 not applicable
the counterexample is fast-check's shrunk case; replay the whole run with the recorded seed, or this case alone with fast-check path 0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:1:1
Other checks on this table
ResultCheckWhat happenedStrength
Tested Customers only see their own data Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Strangers cannot read this table Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)
Tested Users cannot move data or promote themselves Tested: none of 50 generated scenarios broke the rule (different users, organisations, and rows each time). Tested (statistical)

File storage, bucket by bucket

We uploaded a file for each made-up user into the folder layout you declared, then tried to list, download, upload, replace, and delete other users' files, signed in and not signed in, the way the Supabase storage API would act for them. A bucket marked public serves every file at its link without consulting any rule, so it is tested as such.

Bucket avatars

1 failed

The rules you declared: Each user's files live in a folder named after their user id. Anyone may read the files (declared public); only the folder's user may upload, replace, or delete them.

Users cannot change other customers' files

Failed

Signed-in users can change or delete other customers' files in the avatars bucket.

What we checked

We signed in as one user and tried to upload into, overwrite, and delete files in another user's or organisation's folder.

What happened

Failed. user-1 (member of org-A) uploaded avatars/planted-by-user-1.pdf into user-2's folder, and the database accepted it.

Why it matters

One customer can replace another customer's files (for example swap a profile picture or an invoice PDF) or delete them.

How to fix it

Replace the condition of "Users can upload an avatar", which never checks which user is asking. Give the insert, update, and delete policies on storage.objects a condition on the file's user folder: bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text (WITH CHECK on insert, USING and WITH CHECK on update, USING on delete).

Evidence strength: Demonstrated. Shown with a concrete, replayable example: the exact user, query, and replay number (seed) are recorded below.

The exact example (for your developer)
Acting as
user-1 (signed in, member of org-A)
Action
UPLOAD on a new file in user-2's folder
Query sent
insert into storage.objects (bucket_id, name, owner, owner_id, metadata) values ($1, $2, $3, $4, '{}'::jsonb)
Parameters
  1. avatars
  2. 20000000-0000-4000-8000-000000000002/planted-by-user-1.pdf
  3. 20000000-0000-4000-8000-000000000001
  4. 20000000-0000-4000-8000-000000000001
What happened
1 row(s) affected
What should happen
refused (0 rows affected, or the policy rejects the new file)
Scenario
org-A = 10000000-0000-4000-8000-000000000001
org-B = 10000000-0000-4000-8000-000000000002
user-1 = 20000000-0000-4000-8000-000000000001 (org-A, member)
user-2 = 20000000-0000-4000-8000-000000000002 (org-B, member)
16 rows seeded in total
Replay
Seed 20260702. Re-run pnpm verify --rls samples/supabase-demo/supabase/migrations --claims samples/supabase-demo/access-claims.json --app-src samples/supabase-demo/app --seed 20260702 --runs 50 to reproduce this exact case.
Technical record
Claim
claim.storage.write.cross-scope.avatars
bucket avatars: A signed-in user cannot upload into, overwrite, or delete files in another user's or organisation's folder.
Evidence record
ev_claim_storage_write_cross_scope_avatars
Content hash
sha256:7b3cefb7db365fabcbebab8cc0568fcd4dd240a2e94345da17668cc07d4ad2d4
Assurance
seeded-deterministic / statistical / sampled (repeatability / strength / scope)
Gate
Failed: 1 assurance-bearing violation(s) detected (property-based). Assurance measure 0.00 (bounded in 0 to 0.95, never certainty).
Notes
attempts: 0 denied, 1 allowed, 0 not applicable
the counterexample is fast-check's shrunk case; replay the whole run with the recorded seed, or this case alone with fast-check path 0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:1:1
Other checks on this bucket
ResultCheckWhat happenedStrength
Tested Strangers cannot upload or delete files Tested: none of 50 generated scenarios broke the rule (different users, organisations, and files each time). Tested (statistical)

Bucket invoice-pdfs

2 failed

The rules you declared: Each organisation's files live in a folder named after the organisation's id. Only the folder's organisation members may read, upload, replace, or delete; visitors without an account may do nothing.

Customers only see their own files

Failed

Signed-in users can read other customers' files in the invoice-pdfs bucket.

What we checked

We uploaded files for made-up users from different organisations, then signed in as each user and tried to list and download the other users' files, the way the Supabase storage API would serve them.

What happened

Failed. user-1 (member of org-A) downloaded invoice-pdfs/file-2.pdf in org-B's folder (uploaded by user-2, org-B) through the bucket's public link; no access rule was consulted.

Why it matters

Invoices, contracts, or documents uploaded by one customer can be downloaded by another. For a public bucket, anyone who has or guesses a file's link can download it without signing in.

How to fix it

Make the bucket private (update storage.buckets set public = false where id = 'invoice-pdfs'): a public bucket serves every file by its link whatever the policies say. Serve files through signed URLs. Drop or narrow "Enable read access for all users" on storage.objects: it allows every file in every bucket unconditionally. Give the select policy on storage.objects a condition on the file's organisation folder: bucket_id = 'invoice-pdfs' and exists (select 1 from public.members m where m.user_id = auth.uid() and m.org_id::text = (storage.foldername(name))[1]).

Evidence strength: Demonstrated. Shown with a concrete, replayable example: the exact user, query, and replay number (seed) are recorded below.

The exact example (for your developer)
Acting as
user-1 (signed in, member of org-A)
Action
GET public file URL on invoice-pdfs/file-2.pdf in org-B's folder (uploaded by user-2, org-B)
Query sent
select public from storage.buckets where id = $1
Parameters
  1. invoice-pdfs
What happened
the bucket is marked public, so the storage API serves invoice-pdfs/file-2.pdf in org-B's folder (uploaded by user-2, org-B) at /storage/v1/object/public/invoice-pdfs/10000000-0000-4000-8000-000000000002/file-2.pdf to anyone with the link, without checking any policy
What should happen
the bucket is private, so every download goes through the storage.objects policies
Scenario
org-A = 10000000-0000-4000-8000-000000000001
org-B = 10000000-0000-4000-8000-000000000002
user-1 = 20000000-0000-4000-8000-000000000001 (org-A, member)
user-2 = 20000000-0000-4000-8000-000000000002 (org-B, member)
16 rows seeded in total
Replay
Seed 20260702. Re-run pnpm verify --rls samples/supabase-demo/supabase/migrations --claims samples/supabase-demo/access-claims.json --app-src samples/supabase-demo/app --seed 20260702 --runs 50 to reproduce this exact case.
Technical record
Claim
claim.storage.read.isolation.invoice-pdfs
bucket invoice-pdfs: A signed-in user never reads or downloads a file outside their own folder (another user's, or another organisation's for organisation folders).
Evidence record
ev_claim_storage_read_isolation_invoice_pdfs
Content hash
sha256:8d85322be3a0e0a75bc2be79c691897a131dd05930d124bd03d7a99b2063d9f3
Assurance
seeded-deterministic / statistical / sampled (repeatability / strength / scope)
Gate
Failed: 1 assurance-bearing violation(s) detected (property-based). Assurance measure 0.00 (bounded in 0 to 0.95, never certainty).
Notes
attempts: 0 denied, 1 allowed, 0 not applicable
the counterexample is fast-check's shrunk case; replay the whole run with the recorded seed, or this case alone with fast-check path 0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0

Strangers cannot download files

Failed

Anyone on the internet can download files from the invoice-pdfs bucket.

What we checked

We acted as a visitor who is not signed in and tried to list and download files from this bucket, including through its public link.

What happened

Failed. A visitor who is not signed in downloaded invoice-pdfs/file-1.pdf in org-A's folder (uploaded by user-1, org-A) through the bucket's public link; no access rule was consulted.

Why it matters

Files meant for one customer can be downloaded by anyone who has or guesses the link, without an account.

How to fix it

Make the bucket private (update storage.buckets set public = false where id = 'invoice-pdfs'): a public bucket serves every file by its link whatever the policies say. Serve files through signed URLs. Drop or narrow "Enable read access for all users" on storage.objects: it allows every file in every bucket unconditionally. Give the select policy on storage.objects to authenticated and a condition on the file's organisation folder: bucket_id = 'invoice-pdfs' and exists (select 1 from public.members m where m.user_id = auth.uid() and m.org_id::text = (storage.foldername(name))[1]).

Evidence strength: Demonstrated. Shown with a concrete, replayable example: the exact user, query, and replay number (seed) are recorded below.

The exact example (for your developer)
Acting as
an anonymous visitor (anon key, not signed in)
Action
GET public file URL on invoice-pdfs/file-1.pdf in org-A's folder (uploaded by user-1, org-A)
Query sent
select public from storage.buckets where id = $1
Parameters
  1. invoice-pdfs
What happened
the bucket is marked public, so the storage API serves invoice-pdfs/file-1.pdf in org-A's folder (uploaded by user-1, org-A) at /storage/v1/object/public/invoice-pdfs/10000000-0000-4000-8000-000000000001/file-1.pdf to anyone with the link, without checking any policy
What should happen
the bucket is private, so every download goes through the storage.objects policies
Scenario
org-A = 10000000-0000-4000-8000-000000000001
org-B = 10000000-0000-4000-8000-000000000002
user-1 = 20000000-0000-4000-8000-000000000001 (org-A, member)
user-2 = 20000000-0000-4000-8000-000000000002 (org-B, member)
16 rows seeded in total
Replay
Seed 20260702. Re-run pnpm verify --rls samples/supabase-demo/supabase/migrations --claims samples/supabase-demo/access-claims.json --app-src samples/supabase-demo/app --seed 20260702 --runs 50 to reproduce this exact case.
Technical record
Claim
claim.storage.anon.read-denied.invoice-pdfs
bucket invoice-pdfs: A visitor who is not signed in cannot list or download any file in the bucket.
Evidence record
ev_claim_storage_anon_read_denied_invoice_pdfs
Content hash
sha256:d990fa43af51950af719c68604f9e25053e3d2ed7bdb2c16a9642531bc5fccb7
Assurance
seeded-deterministic / statistical / sampled (repeatability / strength / scope)
Gate
Failed: 1 assurance-bearing violation(s) detected (property-based). Assurance measure 0.00 (bounded in 0 to 0.95, never certainty).
Notes
attempts: 0 denied, 1 allowed, 0 not applicable
the counterexample is fast-check's shrunk case; replay the whole run with the recorded seed, or this case alone with fast-check path 0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0
Other checks on this bucket
ResultCheckWhat happenedStrength
Tested Users cannot change other customers' files Tested: none of 50 generated scenarios broke the rule (different users, organisations, and files each time). Tested (statistical)
Tested Strangers cannot upload or delete files Tested: none of 50 generated scenarios broke the rule (different users, organisations, and files each time). Tested (statistical)

Bucket client-files

2 failed

The rules you declared: Each organisation's files live in a folder named after the organisation's id. Only the folder's organisation members may read, upload, replace, or delete; visitors without an account may do nothing.

Customers only see their own files

Failed

Signed-in users can read other customers' files in the client-files bucket.

What we checked

We uploaded files for made-up users from different organisations, then signed in as each user and tried to list and download the other users' files, the way the Supabase storage API would serve them.

What happened

Failed. user-1 (member of org-A) listed the client-files bucket and could read 1 file(s) that are not theirs: client-files/file-2.pdf in org-B's folder (uploaded by user-2, org-B).

Why it matters

Invoices, contracts, or documents uploaded by one customer can be downloaded by another. For a public bucket, anyone who has or guesses a file's link can download it without signing in.

How to fix it

Drop or narrow "Enable read access for all users" on storage.objects: it allows every file in every bucket unconditionally. Give the select policy on storage.objects a condition on the file's organisation folder: bucket_id = 'client-files' and exists (select 1 from public.members m where m.user_id = auth.uid() and m.org_id::text = (storage.foldername(name))[1]).

Evidence strength: Demonstrated. Shown with a concrete, replayable example: the exact user, query, and replay number (seed) are recorded below.

The exact example (for your developer)
Acting as
user-1 (signed in, member of org-A)
Action
LIST / download files
Query sent
select id::text as id, name from storage.objects where bucket_id = $1
Parameters
  1. client-files
What happened
2 file(s) listed, 1 outside user-1's organisation's folder: client-files/file-2.pdf in org-B's folder (uploaded by user-2, org-B)
What should happen
only files in user-1's organisation's folder
Scenario
org-A = 10000000-0000-4000-8000-000000000001
org-B = 10000000-0000-4000-8000-000000000002
user-1 = 20000000-0000-4000-8000-000000000001 (org-A, member)
user-2 = 20000000-0000-4000-8000-000000000002 (org-B, member)
16 rows seeded in total
Replay
Seed 20260702. Re-run pnpm verify --rls samples/supabase-demo/supabase/migrations --claims samples/supabase-demo/access-claims.json --app-src samples/supabase-demo/app --seed 20260702 --runs 50 to reproduce this exact case.
Technical record
Claim
claim.storage.read.isolation.client-files
bucket client-files: A signed-in user never reads or downloads a file outside their own folder (another user's, or another organisation's for organisation folders).
Evidence record
ev_claim_storage_read_isolation_client_files
Content hash
sha256:9b949f9f1479c9d6f3de52f29c285d80f04ff38c79c0903b1e3ad0e301d51a28
Assurance
seeded-deterministic / statistical / sampled (repeatability / strength / scope)
Gate
Failed: 1 assurance-bearing violation(s) detected (property-based). Assurance measure 0.00 (bounded in 0 to 0.95, never certainty).
Notes
attempts: 0 denied, 1 allowed, 0 not applicable
the counterexample is fast-check's shrunk case; replay the whole run with the recorded seed, or this case alone with fast-check path 0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0

Strangers cannot download files

Failed

Anyone on the internet can download files from the client-files bucket.

What we checked

We acted as a visitor who is not signed in and tried to list and download files from this bucket, including through its public link.

What happened

Failed. A visitor who is not signed in listed the client-files bucket and saw 2 of 2 file(s).

Why it matters

Files meant for one customer can be downloaded by anyone who has or guesses the link, without an account.

How to fix it

Drop or narrow "Enable read access for all users" on storage.objects: it allows every file in every bucket unconditionally. Give the select policy on storage.objects to authenticated and a condition on the file's organisation folder: bucket_id = 'client-files' and exists (select 1 from public.members m where m.user_id = auth.uid() and m.org_id::text = (storage.foldername(name))[1]).

Evidence strength: Demonstrated. Shown with a concrete, replayable example: the exact user, query, and replay number (seed) are recorded below.

The exact example (for your developer)
Acting as
an anonymous visitor (anon key, not signed in)
Action
LIST / download files
Query sent
select count(*)::int as n from storage.objects where bucket_id = $1
Parameters
  1. client-files
What happened
2 of 2 file(s) visible without signing in
What should happen
0 files (or permission denied)
Scenario
org-A = 10000000-0000-4000-8000-000000000001
org-B = 10000000-0000-4000-8000-000000000002
user-1 = 20000000-0000-4000-8000-000000000001 (org-A, member)
user-2 = 20000000-0000-4000-8000-000000000002 (org-B, member)
16 rows seeded in total
Replay
Seed 20260702. Re-run pnpm verify --rls samples/supabase-demo/supabase/migrations --claims samples/supabase-demo/access-claims.json --app-src samples/supabase-demo/app --seed 20260702 --runs 50 to reproduce this exact case.
Technical record
Claim
claim.storage.anon.read-denied.client-files
bucket client-files: A visitor who is not signed in cannot list or download any file in the bucket.
Evidence record
ev_claim_storage_anon_read_denied_client_files
Content hash
sha256:1f5495f03cb8c8b9448fecaea8599d0d2342916bb954fd09abb609b2d0b77b01
Assurance
seeded-deterministic / statistical / sampled (repeatability / strength / scope)
Gate
Failed: 1 assurance-bearing violation(s) detected (property-based). Assurance measure 0.00 (bounded in 0 to 0.95, never certainty).
Notes
attempts: 0 denied, 1 allowed, 0 not applicable
the counterexample is fast-check's shrunk case; replay the whole run with the recorded seed, or this case alone with fast-check path 0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0
Other checks on this bucket
ResultCheckWhat happenedStrength
Tested Users cannot change other customers' files Tested: none of 50 generated scenarios broke the rule (different users, organisations, and files each time). Tested (statistical)
Tested Strangers cannot upload or delete files Tested: none of 50 generated scenarios broke the rule (different users, organisations, and files each time). Tested (statistical)

Secrets in browser code

Source samples/supabase-demo/app

4 failed

We read 9 text files of your app's source (framework: vite), of which 7 ship to the browser by the framework's conventions, and matched them against known secret formats. Nothing was run. 1 Supabase anon key appeared, which is public by design and not flagged. Matched values are shown redacted.

Limits: a pattern scan finds known key formats and secret-named settings; a key in an unknown format, built from pieces, or injected only at deploy time is not seen. Where the framework's conventions do not settle whether a file ships to the browser, a match is reported at medium severity for a human to confirm.

No Supabase admin key in the browser

Failed

A Supabase service-role key is in code that ships to the browser.

What we checked

We scanned the files that ship to your users' browsers for a Supabase service-role key. That key skips every access rule in your database.

What happened

The code scan matched 1 place(s) in code that ships or may ship to the browser.

Why it matters

Anyone who opens your site can copy the key and read, change, or delete every row and file in your project, ignoring all access rules.

How to fix it

Rotate the key in the Supabase dashboard now, remove it from the client code, and move the work that needs it into an Edge Function or server route.

Evidence strength: Found by code scan. A pattern scan of your app's source found this at the file and line shown. A pattern match, not a demonstration of misuse: confirm the line, and rotate the key if it is real.

Where it is (file and line, values redacted)
  • High A Supabase service-role key (a JWT whose payload says role service_role) in code that ships to the browser (vite app source, bundled for the browser): src/lib/admin.ts, line 7. Matched: eyJhbG... (240 characters). samples/supabase-demo/app/src/lib/admin.ts, line 7
Technical record
Claim
claim.secrets.client.supabase-service-key
No Supabase service-role key (a JWT with role service_role, or an sb_secret_ key) appears in code that ships to the browser.
Evidence record
ev_claim_secrets_client_supabase_service_key
Content hash
sha256:31500755f76baff5f9f1b1955a037ca44760d60f54bc3ec4c5f536a6974073c4
Assurance
deterministic / heuristic / single-instance (repeatability / strength / scope)
Gate
Failed: 1 assurance-bearing violation(s) detected (static-analysis). Assurance measure 0.00 (bounded in 0 to 0.95, never certainty).
Notes
framework read from package.json: vite
9 text file(s) scanned, 7 classified as shipping to the browser
0 .env file(s) not opened (by design)
1 Supabase anon key seen; public by design, not flagged

No other service secret in the browser

Failed

A service secret key is in code that ships to the browser.

What we checked

We scanned the files that ship to the browser for secret keys of common services: payments, cloud storage, AI, source control, messaging, and private keys.

What happened

The code scan matched 1 place(s) in code that ships or may ship to the browser.

Why it matters

Anyone can copy the key and use the service as you: charge cards, run up AI or cloud bills, or read your data there.

How to fix it

Rotate the key with the provider, remove it from the client code, and call the service from a server route or Edge Function instead.

Evidence strength: Found by code scan. A pattern scan of your app's source found this at the file and line shown. A pattern match, not a demonstration of misuse: confirm the line, and rotate the key if it is real.

Where it is (file and line, values redacted)
  • High An AWS access key id in code that ships to the browser (vite app source, bundled for the browser): src/lib/uploads.ts, line 6. Matched: AKIAIO... (20 characters). samples/supabase-demo/app/src/lib/uploads.ts, line 6
Technical record
Claim
claim.secrets.client.service-key
No other service secret key (payment, cloud, AI, source control, messaging, or a private key) appears in code that ships to the browser.
Evidence record
ev_claim_secrets_client_service_key
Content hash
sha256:86e40e8464a2b5326812b20b1c44c11b84c63d1968d1299c802ef579d2595eb7
Assurance
deterministic / heuristic / single-instance (repeatability / strength / scope)
Gate
Failed: 1 assurance-bearing violation(s) detected (static-analysis). Assurance measure 0.00 (bounded in 0 to 0.95, never certainty).
Notes
framework read from package.json: vite
9 text file(s) scanned, 7 classified as shipping to the browser
0 .env file(s) not opened (by design)
1 Supabase anon key seen; public by design, not flagged

No token-signing secret in the browser

Failed

A JWT signing secret is in code that ships to the browser.

What we checked

We scanned the files that ship to the browser for a hard-coded JWT signing secret.

What happened

The code scan matched 1 place(s) in code that ships or may ship to the browser.

Why it matters

With the signing secret, anyone can mint login tokens for any user, including admin roles.

How to fix it

Rotate the JWT secret (in Supabase: Project Settings, then JWT keys), remove it from the client, and keep it only in server-side configuration.

Evidence strength: Found by code scan. A pattern scan of your app's source found this at the file and line shown. A pattern match, not a demonstration of misuse: confirm the line, and rotate the key if it is real.

Where it is (file and line, values redacted)
  • High A hard-coded JWT signing secret in code that ships to the browser (vite app source, bundled for the browser): src/lib/auth.ts, line 3. Matched: supe... (55 characters). samples/supabase-demo/app/src/lib/auth.ts, line 3
Technical record
Claim
claim.secrets.client.jwt-secret
No JWT signing secret is hard-coded in code that ships to the browser.
Evidence record
ev_claim_secrets_client_jwt_secret
Content hash
sha256:d4c26c7795a470f4ef5b2ca44a3bfcf95360d6afc9b1c19840cbf1dd654f8337
Assurance
deterministic / heuristic / single-instance (repeatability / strength / scope)
Gate
Failed: 1 assurance-bearing violation(s) detected (static-analysis). Assurance measure 0.00 (bounded in 0 to 0.95, never certainty).
Notes
framework read from package.json: vite
9 text file(s) scanned, 7 classified as shipping to the browser
0 .env file(s) not opened (by design)
1 Supabase anon key seen; public by design, not flagged

No secret behind a browser-exposed setting

Failed

A secret is read through a browser-exposed environment variable.

What we checked

We looked for environment variables that the build tool copies into the browser bundle (names starting VITE_, NEXT_PUBLIC_, REACT_APP_, EXPO_PUBLIC_, NUXT_PUBLIC_, GATSBY_, or PUBLIC_) whose names say they hold a secret.

What happened

The code scan matched 1 place(s) in code that ships or may ship to the browser.

Why it matters

Whatever value that setting holds at build time is visible to every visitor, even though it never appears in your source code.

How to fix it

Rename the variable without the public prefix, read it only in server code (an Edge Function or API route), and rotate the key if a build with it was ever deployed.

Evidence strength: Found by code scan. A pattern scan of your app's source found this at the file and line shown. A pattern match, not a demonstration of misuse: confirm the line, and rotate the key if it is real.

Where it is (file and line, values redacted)
  • High The browser-exposed setting VITE_STRIPE_SECRET_KEY, whose name says it holds a secret in code that ships to the browser (vite app source, bundled for the browser): src/lib/billing.ts, line 4. Matched: variable name VITE_STRIPE_SECRET_KEY (its value lives in the build environment and was not read). samples/supabase-demo/app/src/lib/billing.ts, line 4
Technical record
Claim
claim.secrets.client.public-env-secret
No secret is read through a browser-exposed environment variable (VITE_, NEXT_PUBLIC_, REACT_APP_, EXPO_PUBLIC_, NUXT_PUBLIC_, GATSBY_, PUBLIC_ prefixes), which the build copies into the bundle.
Evidence record
ev_claim_secrets_client_public_env_secret
Content hash
sha256:17c276fdaa91ee58398376716d649bd0cd0f51484de3a8331912a31b7ccf3be9
Assurance
deterministic / heuristic / single-instance (repeatability / strength / scope)
Gate
Failed: 1 assurance-bearing violation(s) detected (static-analysis). Assurance measure 0.00 (bounded in 0 to 0.95, never certainty).
Notes
framework read from package.json: vite
9 text file(s) scanned, 7 classified as shipping to the browser
0 .env file(s) not opened (by design)
1 Supabase anon key seen; public by design, not flagged

Settings scan (heuristic)

We also read the database's own settings after applying your migrations, including the storage buckets and the rules on stored files, and looked for patterns that often cause problems. Each flag says whether a live check above confirmed it.

Row-level security is switched on

1 flag

We read the database settings for every table your app exposes and checked that row-level security (the per-row access rules) is switched on.

  • High Row-level security is disabled on public.invoice_line_items. With Supabase's default grants, anyone holding the public anon key can read and write every row. 20260905093000_invoice_line_items.sql, line 3 Confirmed by live check: Customers only see their own data; Users cannot change other customers' data; Strangers cannot read this table; Strangers cannot change this table; Users cannot move data or promote themselves.

No rule that lets everyone in

2 flags

We looked for access rules whose condition is simply true, which allows every row for every user.

  • High Policy "Enable insert for all users" on public.client_notes is WITH CHECK (true) for INSERT (roles: public): it allows every row unconditionally. 20260910141500_client_notes.sql, line 19 Confirmed by live check: Users cannot change other customers' data; Strangers cannot change this table.
  • High Policy "Enable read access for all users" on public.clients is USING (true) for SELECT (roles: public): it allows every row unconditionally. 20260901120100_row_level_security.sql, line 35 Confirmed by live check: Customers only see their own data; Strangers cannot read this table.

Edit rules check the result

2 flags

We looked for edit (UPDATE) rules that state which rows may be edited but not what the edited row may become.

  • Medium UPDATE policy "Users can update their own invoices" on public.invoices has no explicit WITH CHECK, so Postgres reuses USING ((created_by = auth.uid())) for the new row. Harmless only if that condition pins the tenant; a lead for review. 20260901120100_row_level_security.sql, line 93 Confirmed by live check: Users cannot move data or promote themselves.
  • Medium UPDATE policy "Users can update own profile" on public.profiles has no explicit WITH CHECK, so Postgres reuses USING ((id = auth.uid())) for the new row. Harmless only if that condition pins the tenant; a lead for review. 20260901120100_row_level_security.sql, line 29 Confirmed by live check: Users cannot move data or promote themselves.

No write rule for strangers

1 flag

We looked for create, edit, or delete rules that apply to visitors who are not signed in (rules with no to clause apply to everyone).

  • Low INSERT policy "Enable insert for all users" on public.client_notes applies to public, which includes anonymous visitors. Its condition may still reject them; the dynamic anon-write check shows whether it does. 20260910141500_client_notes.sql, line 19 Confirmed by live check: Strangers cannot change this table.

Private files are not in a public bucket

2 flags

We read the bucket settings created by your migrations and looked for buckets marked public. Supabase serves every file in a public bucket to anyone with its link, without checking any access rule.

  • Info Bucket "avatars" is marked public; the claims declare it public-read, so this is recorded for information. 20260915100000_storage.sql, line 6 Not confirmed by a live check: worth a human look, may be harmless.
  • High Bucket "invoice-pdfs" is marked public, but the claims declare its files per organisation: every file is served to anyone with its link, whatever the policies say. 20260915100000_storage.sql, line 7 Confirmed by live check: Customers only see their own files; Strangers cannot download files.

No file rule that lets everyone in

1 flag

We looked for access rules on stored files whose condition is simply true, which applies to every file in every bucket.

  • High Policy "Enable read access for all users" on storage.objects is USING (true) for SELECT (roles: public): it allows every file in every bucket unconditionally. 20260915100000_storage.sql, line 54 Confirmed by live check: Customers only see their own files (bucket invoice-pdfs); Strangers cannot download files (bucket invoice-pdfs); Customers only see their own files (bucket client-files); Strangers cannot download files (bucket client-files).

File rules check who owns the file

2 flags

We looked for access rules on stored files that only name the bucket and never check the requesting user (no auth.uid(), owner column, or helper function).

  • Info SELECT policy "Avatar images are publicly accessible" on storage.objects checks only the bucket; the claims declare "avatars" public-read, so this is recorded for information. 20260915100000_storage.sql, line 11 Not confirmed by a live check: worth a human look, may be harmless.
  • Medium INSERT policy "Users can upload an avatar" on storage.objects ((bucket_id = 'avatars'::text)) never checks which user is asking: no auth.uid(), JWT sub or email claim, or helper function (auth.role() and the JWT role say only that the caller is signed in). Any signed-in user may pass it for any file it matches. 20260915100000_storage.sql, line 15 Confirmed by live check: Users cannot change other customers' files.

Method and reproducibility

Every run builds a fresh, private copy of the database in memory (Postgres compiled to WebAssembly), recreates the Supabase roles (anon, authenticated, service_role), the auth.uid() and auth.jwt() helpers, and Supabase's default permissions, then applies your migrations. For each declared table and rule, a property-based generator (fast-check) creates organisations, users, memberships, and rows; we then send queries exactly as your app's users could, signed in as each generated user (or not signed in at all). Declared storage buckets get one file per generated user, and the same users try to reach each other's files through the storage tables, as the Supabase storage API would on their behalf. Any read or write the rules forbid is recorded with the scenario and the replay seed. The app's source, when given, is only read, never run. No live app, live database, or third-party system is ever contacted.

Replay command
pnpm verify --rls samples/supabase-demo/supabase/migrations --claims samples/supabase-demo/access-claims.json --app-src samples/supabase-demo/app --seed 20260702 --runs 50
Seed and runs
seed 20260702, 50 scenarios per table and check
Migrations
20260901120000_initial_schema.sql: applied
20260901120100_row_level_security.sql: applied
20260905093000_invoice_line_items.sql: applied
20260910141500_client_notes.sql: applied
20260915100000_storage.sql: applied
Migrations hash
sha256:d31c0afff39d0ff8c820c9943db79354010aea1a69454bc8528eefd8ae8bbd14
Claims file
samples/supabase-demo/access-claims.json sha256:84b9dccfba2958cac6a880e7b0c2115466848d04272da9bd94239663731db79f
App source
samples/supabase-demo/app, 9 files read sha256:d3f43a83df4ee0f192c6a3b6a24dad05ce6fcc080307db91e4e7e315a8f973e8
Tool versions
harness 0.2.1, shim supabase-shim@1.1.0, pglite 0.5.8, postgres 18.3, fastCheck 4.8.0, secretsRules client-secrets-rules@1.2.0; rls-catalogue@1.1.1
Gate policy
gate-policy-rls-defaults@1.1.0: live checks may close a claim at the statistical tier; a settings or code scan can flag but never close.
Isolation
  • Your migrations cannot read or write files on the machine running the check: yes
  • No network access from inside the database: yes
  • Cannot start programs on the machine running the check: yes
  • No secrets or environment variables passed in: yes
  • Hard time limit (the run is stopped if it hangs): yes
  • Operating-system memory limit (WebAssembly caps memory at 4 GiB instead): no
  • Operating-system privilege drop (not needed for SQL in WebAssembly; not applied): no
Evidence records
60, each with a content hash (listed per check under "Technical record").