The problem
Supabase is on my skills list, but most "rescue my AI-built app" jobs I see on Upwork are not the Next.js server-backed shape I'd already written up. They're Lovable or Bolt output: a Vite or Next frontend talking straight to Supabase from the browser, with the anon key in the bundle and the database handling auth and tenant isolation through Row-Level Security. When that RLS is off, permissive, or bypassed by a leaked service key, every customer's data is one PostgREST request away from anyone else's. I wanted a case specific to that shape, with proof instead of a checklist.
What I built
The app is a customer portal for Tallgrass Precision Components, a fictional machine shop. Customers log in, see their own orders, download their quality certificates, and open RMAs. Staff see everything.
I generated the first version with headless Claude Code (claude -p, Sonnet) on 2026-10-12, prompting it
as a non-technical founder asking specifically for the Lovable pattern. Its first pass was careful: real
RLS policies, a private storage bucket, the service key kept out of .env with a warning not to put it
there. That isn't what most rescue jobs look like, so I reverted the before/ app to the Supabase-specific
flaw classes I actually see — RLS disabled, RLS "on" but using (true), a profile row whose tenant and
role the user can edit, a leaked service_role key, a public storage bucket, an unchecked SECURITY DEFINER function, a missing WITH CHECK, an edge function with open CORS and no auth, and a tenant invite
code anyone could read — and documented each one I injected.
How it works
before/ and after/ run against two schemas in the same local Postgres instance, through the same
PostgREST endpoint and the same anon key, so both can be attacked live without restarting anything. The
hardened app scopes every policy to tenant_id = current_tenant_id() or is_staff(), where
current_tenant_id() is a SECURITY DEFINER helper that reads the caller's own profile row — never a
value the client supplies.
Security and control
- RLS enabled on every tenant table, with real per-tenant policies, not
using (true). - No update path for a customer's own
tenant_idorrole— set once, server-side, at signup. - No service_role key anywhere in the client; it exists only as an edge function secret.
- Storage bucket private, with policies scoped by tenant path segment.
- The
SECURITY DEFINERRPC now runsSECURITY INVOKERplus an explicit staff check. - The edge function requires a verified session, is rate-limited, and never echoes raw Postgres errors.
- A plain-SQL RLS lint runs alongside the exploits and documents its own blind spot: it can't tell a real policy from a permissive one, which is exactly why the exploits exist too.
Results
Measured by tests/run.mjs on 2026-10-13:
- 13 findings: 4 Critical, 5 High, 3 Medium, 1 Low.
- 13/13 exploits succeed against the before app. 0/13 succeed against the after app.
- 8/8 functional tests pass against the after app.
- An unauthenticated request with just the anon key read every RMA across both demo tenants in before; blocked in after.
- A customer's own session read all 6 orders across 2 companies in before; only their own 3 in after.
- The service_role key was found verbatim in the before client bundle; absent from after.
Stack
Vite + React (JavaScript), Supabase (Postgres 17 with Row-Level Security, Storage, Edge Functions, GoTrue), Supabase CLI, Node test runner, Playwright, Claude Code (claude -p, Sonnet) for the first pass.
What I'd do for your company
If you have a Supabase app built with Lovable, Bolt, Claude, or Cursor and want it in front of real customers, I would: read every RLS policy, not just whether RLS is on; write an exploit for each gap so you can see it's real; fix them without rewriting what works; and hand back the fixed database, the test suite, and a plain-language report.
Tallgrass Precision Components is a fictional company used for this demo.





