A client's internal app (an inventory dashboard connected to 4 live Shopify stores) had been built fast, the way a lot of tools get built now. Then an alert came in from an outside scanning service: the repo was public.
I traced it the same hour, and the real problem wasn't the repo. It was a database rule that had survived from the prototype:
create policy "Allow all for demo" on orders for all using (true); Every table had one. Anyone holding the public anon key (the one that ships in every frontend bundle) could read the orders table, read 4 live Shopify admin tokens, and write to the table that decides who's an admin.
I confirmed it with nothing but the anon key, then locked it down.
How this happens to people who know better
Nobody writes using (true) on purpose for production. It happens like this:
- You prototype fast, often with an AI builder, and RLS gets in the way, so a "demo" policy goes in.
- The demo works and becomes the product.
- The repo gets flipped to public to share a preview.
- The anon key, which is public anyway, now opens everything.
Each step on its own looks harmless. Together they make a breach. AI tools have made steps 1 and 2 take minutes, which is why this keeps coming up so much in 2026.
The first 24 hours
- Repo set to private.
- Revoked all table access from
anon. Every open policy becameto authenticated using (is_staff()). - Token columns are readable by the service role only, enforced with column grants, not just policies.
- Sign-up turned off. It was open on an internal tool, so anyone could have made an account.
- A shared
checkCaller()guard went onto 18 edge functions. The anon key is a valid JWT, so "requires auth" on its own proves nothing. - Edge logs showed no third-party reads, but they only go back 24 hours. So I treated every token as stolen anyway.
The part most guides skip: rotation
Re-running Shopify OAuth does not kill old tokens. They stay valid until you rotate the app's client secret and revoke the old one. The order that actually worked:
- Find every place the secret lives:
.envfiles, Vercel env, Supabase secrets, database rows, webhook URLs. - Rotate the client secret and mint new tokens.
- Update all of those places, then redeploy.
- Revoke the old secret.
- Test from the attacker's side: old token → 401, new token → 200, unsigned webhook → 401.
We also rotated 47 webhook URL tokens. And when I redeployed, Vercel blocked two builds for a known framework CVE, so "fix the leak" turned into "patch the framework" in the same hour.
What I run now, every day at 10:00
A single script scans everything I can reach:
- Every local repo: tracked
.envfiles, plus secrets in files and git history - GitHub: any repo that's public
- Every Supabase project: RLS off,
using (true)policies, secret columns visible to anon,security definerfunctions callable by anon, public buckets, sign-up state, edge functions without a guard - Vercel: env vars marked public that look like secrets
- Live sites: the HTML and JS bundles, checked against my own
.envvalues -
npm auditon production dependencies
It writes a report and fixes nothing on its own. Real findings get fixed. Intended exceptions go into an allowlist with a written reason, and nothing gets quietly ignored.
The checklist I now apply before anything ships
- Repo private by default,
.env*in.gitignorebefore the first commit - Only anon/publishable keys in frontend code
- RLS on every table, never
using (true)for anon on private data - Webhooks verified by signature with a constant-time compare
- Verify like an attacker: the anon key and no auth against tables, RPCs and functions
AI made building ten times faster. It did nothing for the security review. If you run tools like this for clients, automate the check before a stranger runs it for you.
More on how I build and secure commerce systems: fahadazhar.site

