RowProof
Sample report

Sample RowProof report (test database)

This is what a RowProof report looks like. It was produced on the public test database, not on a client's app, so every name, row and number below comes from that database and from one recorded run.

Method

  1. Script, read only. rls-audit.sql ran inside BEGIN TRANSACTION READ ONLY and ended with ROLLBACK. A write attempted in the same transaction was rejected by the database: ERROR: cannot execute CREATE TABLE in a read-only transaction.
  2. Two accounts. User A and user B were simulated at database level: role authenticated plus the JWT subject claim that auth.uid() reads, set inside each transaction. In a real Standard review the same tests go through your app's own client on staging, with two test accounts I sign up myself.
  3. Fix and retest. Each confirmed finding has the test that showed it, the fix as SQL, and the same test again after the fix.
  4. Script again. The script was run a second time after the fixes, also read only, to show what changed.

Summary

The script returned 19 findings covering all 16 check IDs. The table with a correct ownership policy (public.safe_items) returned none.

2Critical
10High
1Medium
4Review
2Info

Three HIGH findings were confirmed with two-account tests, fixed and retested below. A fourth (C07) was confirmed but left unfixed in this sample. After the three fixes the script returned 16 findings across 13 check IDs: C05, C13 and C16 no longer appear.

Worked findings

HIGH C05 Any signed-in user can read every order

public.orders :: "logged in reads"
Script

The SELECT policy only checks that the caller is signed in: (auth.uid() IS NOT NULL).

Evidence: user B asks for user A's orders
-- role authenticated, auth.uid() = user B
select id, user_id, total from public.orders where user_id = '<user A>';
 id |               user_id                | total
  1 | 00000000-0000-0000-0000-00000000000a |   100
  2 | 00000000-0000-0000-0000-00000000000a |   250
  3 | 00000000-0000-0000-0000-00000000000a |    75
(3 rows)
Impact

Any customer who creates an account can download every other customer's order history, including amounts. No hacking skill is needed: the same request your app already makes, with a different filter.

Fix
alter policy "logged in reads" on public.orders
  using ((select auth.uid()) = user_id);
Retest: same request as user B
select count(*) from public.orders where user_id = '<user A>';
 rows_of_user_a_visible_to_b
                           0
select id, user_id, total from public.orders;
  4 | 00000000-0000-0000-0000-00000000000b |    40   -- only B's own order

HIGH C16 Users can upgrade their own plan

public.profiles :: profiles_update
Script

The UPDATE policy lets users edit their own row, and the authenticated role can write the column plan.

Evidence: user B edits their own plan
-- role authenticated, auth.uid() = user B
update public.profiles set plan = 'pro' where id = '<user B>' returning id, plan;
 00000000-0000-0000-0000-00000000000b | pro
UPDATE 1
Impact

A free user can switch themselves to the paid plan from the browser console, without paying. The row-level policy is correct (users only touch their own row); the problem is which columns they may change.

Fix
revoke update on public.profiles from authenticated;
grant update (email) on public.profiles to authenticated;
-- grant only the columns your profile form edits; set plan from the server (for example a Stripe webhook)
Retest
update public.profiles set plan = 'pro' where id = '<user B>';
ERROR:  permission denied for table profiles
update public.profiles set email = 'b2@example.test' where id = '<user B>';
UPDATE 1   -- the editable column still works

HIGH C13 Users can post in someone else's name

public.comments :: comments_insert
Script

The INSERT policy checks only (length(body) > 0) and never compares user_id to the caller, while authenticated can write user_id.

Evidence: user B inserts a comment owned by user A
-- role authenticated, auth.uid() = user B
insert into public.comments (id, user_id, body)
  values (1, '<user A>', 'written by B in the name of A');
INSERT 0 1
-- stored row: user_id = 00000000-0000-0000-0000-00000000000a
Impact

Anyone signed in can create content that your app will show as written by another user. On a marketplace or a review section that is impersonation.

Fix
alter policy comments_insert on public.comments
  with check ((select auth.uid()) = user_id and length(body) > 0);
Retest
insert into public.comments (id, user_id, body) values (2, '<user A>', '...');
ERROR:  new row violates row-level security policy for table "comments"
insert into public.comments (id, user_id, body) values (3, '<user B>', 'my own comment');
INSERT 0 1   -- B can still post as B

HIGH C07 The C05 fix is not enough on its own

public.v_orders

After the C05 fix, an anonymous visitor (role anon, not signed in) still reads order data through the view, because the view runs with its owner's privileges and skips Row Level Security:

select count(*), sum(total) from public.v_orders;   -- role anon
4 | 465

Fix, not applied in this sample: alter view public.v_orders set (security_invoker = true); or revoke the view from anon. This is why a report lists every path to the data, not only the first one found.

Other findings from the script

Listed as the script reported them on the test database. Apart from C07 above, these were not tested with accounts in this sample; in a real review each HIGH or CRITICAL finding is confirmed or dismissed with a test.

CheckSeverityObjectWhat it means
C01CRITICALpublic.leakyRLS is off and anon can SELECT: anyone with the public key reads the table.
C03CRITICALpublic.posts :: p_insert_anyAnon INSERT policy with check: true: anyone can write rows.
C03HIGHpublic.posts :: p_public_readAnon SELECT policy with using: true. Fine only if the posts are meant to be public.
C07HIGHpublic.v_ordersView without security_invoker, readable by anon. Confirmed above.
C08HIGHpublic.mv_statsMaterialized view readable by authenticated; materialized views have no RLS.
C09HIGHpublic.admin_reset()SECURITY DEFINER function callable by anon and authenticated through /rpc.
C12HIGHstorage.objects :: obj_any_insertStorage upload policy for public that never checks the owner.
C15HIGHpublic.admin_docs :: admin_docs_readAccess decided by user_metadata, which users can edit themselves.
C15HIGHpublic.is_admin()Boolean helper reading user-editable metadata, used by a policy.
C10MEDIUMpublic.admin_reset()SECURITY DEFINER function without a fixed search_path.
C04REVIEWpublic.notes :: notes_no_toPolicy without a TO clause, so it also applies to anon.
C11REVIEWstorage bucket avatarsPublic bucket: fine for avatars, never for invoices or IDs.
C14REVIEWpublic.leakySecret-looking column (secret_note) readable by anon.
C14REVIEWpublic.profilesPersonal column (email) readable by authenticated; confirm who reads each row.
C02INFOpublic.latentRLS off but no API grants yet; exposed the day someone adds one.
C06INFOpublic.empty_rlsRLS on with no policy: safe, but the app gets empty results.

What passed

public.safe_items has RLS on and one SELECT policy for authenticated that compares the row to the caller: ((select auth.uid()) = user_id). The script returned no finding for it, before or after the fixes. A report lists what passed so you know it was checked, not skipped.

Limits