InvestnovationStudio

Supabase security audit

Your policies exist. That is not the same as your policies working.

-invoices rls enabled, 2 policies scanner says OK
+invoices tenant A modified tenant B rows proven

Its UPDATE policy is using (true), and any logged-in user can silently rewrite every other customer’s invoices. A linter can grep for that literal string. It cannot catch a predicate that resolves to true through a join, or a correct USING with no WITH CHECK behind it.

I do not infer anything from your policy text. I seed real rows owned by two synthetic tenants, assume each tenant’s identity for real, and try to reach the other one’s data. Reads, writes, and deletes. Then I roll all of it back.

If data comes back, it comes back. There is no interpretation step left to get wrong.

What the usual test misses

A correct SELECT policy hides a broken UPDATE policy from the obvious test, because the obvious test reads a column:

-- The test everyone writes. It cannot fail.
update invoices set total = 0 where owner_id = '<other-tenant>';
--                               ^ reads a column, so the SELECT policy
--                                 applies, hides the row, nothing matches.
--                                 Your UPDATE policy was never evaluated.

-- The test that finds it.
update invoices set total = 0;
--                             no WHERE, no column read, no SELECT policy.
--                             Only the UPDATE policy applies. If it says
--                             using (true), every row is now yours.

DELETE has the identical flaw and a worse ending. A DELETE policy of using (true) means any authenticated user can empty the table. Reads perfectly scoped, deletes wide open. I have found exactly this shape more than once.

The same blindness applies to tables that own nothing themselves. A messages table has a conversation_id and no user_id, so anything looking for an owner column skips it. Those are usually the tables holding the content that matters.

Run the free tool first

The prover is open source, MIT licensed, and yours before you pay me anything. It refuses to run against a database that looks live, and it reports what it cannot prove rather than passing it silently.

npx rls-sentinel --db "$DATABASE_URL"

github.com/investnovation/rls-sentinel

If it comes back clean, you have a smaller problem than you feared and you will pay me less. That is a good outcome and I would rather you have it.

What the audit adds

The prover seeds tables and proves isolation on those tables. What it cannot see is everything that is not a table. Here is a schema where RLS is on, the policy is correct, the proof run passes, and a logged-in user still reads another customer’s invoices:

-- The table. RLS on, the policy is correct, the proof run passes.
create policy own_rows on invoices
  for select to authenticated using (owner_id = auth.uid());

-- The view somebody added in week two.
create view invoice_summary as select customer, total from invoices;
grant select on invoice_summary to authenticated;

-- Signed in as Ada, who owns exactly one of the three invoices.
select customer, total from invoices;
--   Ada Ltd  |  1200                                        (1 row)

select customer, total from invoice_summary;
--   Ada Ltd  |   1200
--   Lin Corp |  98000
--   Lin Corp |  45000                                       (3 rows)

-- A view runs as its owner. This makes it run as the caller.
alter view invoice_summary set (security_invoker = true);

Ada owns one invoice. Through the view she reads all three, including another tenant’s $98,000 one. Nothing here is misconfigured. A view runs as its owner unless you say otherwise, and security_invoker did not exist before Postgres 15, so most of the views in most projects were written without it.

The same shape repeats in storage rules, in the bodies of SECURITY DEFINER functions, in what can reach the service-role key, and in auth configuration. None of it is a policy, so no amount of proving policies will find it.

Why a person and not a bigger scanner

A scanner returns everything matching a pattern, and most of those matches are deliberate. A view that aggregates across tenants for an internal dashboard is supposed to do that, and setting security_invoker on it breaks the dashboard.

So the list is not the work. Deciding which entries are real, and which fix does not break the thing it was built for, is the work, and that part does not automate. It is the whole of what you are paying me for.

If what you need is breadth rather than depth, dependency CVEs, secrets in git history, injection, the OWASP list across everything else you run, that is a real purchase and it is not this one. Any decent application security firm covers it, and I would rather say so than sell you the wrong thing.

Exposure Check

$500

I read the parts of your project no probe reaches. Views, SECURITY DEFINER bodies, storage rules, service-role reachability, auth config. The leaks, with the exact fix for each, chosen not to break what the thing was built for. The proof run is included and it was always free. If I find nothing, so is this.

48 hours

Full Audit

$5,500

The Exposure Check finds where the defaults are wrong. This reads your authorization model: every policy, ownership traced through joins, the branches a probe cannot reach. Written up, severity ranked, with the exact fix for each rather than a description of one. Retest after you fix, included.

3 to 5 days

Audit and Remediation

$9,000

The full audit, and I implement the fixes and verify them with a second proof run.

1 to 2 weeks

Continuous Verification

$750/mo

The prover wired into your CI as a merge gate, so isolation is re-proven on every pull request. Monthly review of each new table, policy, function, and bucket.

Ongoing

In scope

  • Cross-tenant read, write, and delete isolation, proven
  • Ownership through foreign keys
  • RLS coverage and policy correctness
  • Anon-key exposure on the public Data API
  • Identity trust boundaries, the IDOR class
  • RPC and SECURITY DEFINER escalation
  • Views that bypass RLS on the tables beneath them
  • Storage bucket policies and signed URLs
  • Service-role key reachability
  • Auth configuration and redirect allowlists

Not in scope

  • Network and infrastructure security
  • Frontend XSS
  • Penetration testing
  • Compliance certification
  • Anything needing production data access

Terms worth stating up front

No production access. I will refuse it. Point me at a preview branch or staging, or send a schema and policy export. No customer data changes hands.

If I find nothing, it is free. At any severity. You keep the policy map and a passing proof run you can put in CI.

Async by default. A call is optional and never the deliverable. Written findings are faster for you and more precise than anything either of us would remember from a meeting.

I work UK and US business hours from Manila, so a question sent at the end of your day usually has an answer by the start of your next one.

Send me your schemaOr pay the $500 now

Exposure Check, $500, invoiced once I have shown you the first finding. If I find nothing, there is nothing to invoice. Paying up front is there if you prefer it, and it buys you nothing I would not do anyway.

Or write to hello@investnovation.com from wherever you read your mail.

Async by default. No discovery call, no scoping meeting, no deck. You send a schema dump and a policy export, I send back findings with the fix for each one. A call is available if you want it, but it is never the deliverable, and nothing waits on one being booked.

Investnovation
Written by the author of rls-sentinel.
How I found this class of bug in my own production system