Sample report from a test app. NotesShop is a demo app we built to show what a VibePatchers report looks like. It is not a real customer, and all keys shown are fake.
Sample report: NotesShop (test app)
Service: Vibe-coded App Rescue ($149 flat, fix-and-verify) App: NotesShop (Vite + React front end, Supabase-style Postgres backend, Stripe checkout) Result: 3 issues found, 3 fixed. Verification suite: 2/14 passing before → 14/14 passing after.
Summary for the owner
| # | Problem | Risk if left alone | Status |
|---|---|---|---|
| 1 | Database security rules let anyone read and change everyone’s data | Any visitor could download every user’s notes and orders, plant data in other accounts, or mark orders “paid” | ✅ Fixed |
| 2 | Secret admin keys were shipped inside the website code | Anyone could open the browser dev tools, copy the keys, and get full admin access to your database and charge/refund through your Stripe account | ✅ Fixed (you must rotate the keys — see below) |
| 3 | Payment webhook accepted fake payment notices | Anyone could send a fake “payment succeeded” message and unlock paid features for free; real Stripe retries were recorded twice | ✅ Fixed |
1. Database rows open to everyone (row-level security)
What was wrong: The notes table had a policy named “Enable all access” (using (true)), which turns the security rules off in practice. The orders table had no row-level security at all. The anonymous (logged-out) role had full table permissions.
What we changed: Removed the permissive policy; turned on and enforced row-level security on notes, orders, and payment_events; removed all table access for logged-out visitors; signed-in users can read/write only their own notes and only read their own orders. Order status can now only be changed by the server (the payment webhook). Change is a single new migration: supabase/migrations/002_rescue_rls.sql.
Evidence: checks A1–A7 (below). Before: a logged-out visitor saw “Alice private” and “Bob private”, inserted a note into Bob’s account, and marked Bob’s order paid. After: all blocked, and Alice can still use the app normally (A6).
2. Secret keys in the browser bundle
What was wrong: The Supabase service-role key (bypasses all security rules) and the Stripe secret key were in .env with the VITE_ prefix, which tells the build tool to paste them into the public JavaScript. We found both in the built file dist/assets/index-*.js. The .env file was also committed to git.
What we changed: The browser now only uses the public anon key plus the signed-in user’s session. Secret keys were renamed to server-only variables (no VITE_ prefix). Checkout sessions are created by the server (/api/create-checkout-session) instead of calling Stripe from the browser. .env is removed from git and ignored; .env.example documents which keys are safe for the browser.
Action required from you: because these keys were public, treat them as stolen. Rotate the Supabase JWT secret/service-role key and roll the Stripe secret key, then update the server environment. (Removing them from the code does not un-leak them; they also remain in old git history.)
Evidence: checks B1–B3. Before: bundle contained a service_role key and sk_test_…. After: only the public anon key is present.
3. Stripe webhook accepted forged events
What was wrong: The handler parsed the JSON first and then re-encoded it to check Stripe’s signature. Re-encoding changes the bytes, so the check always failed — and the code had been “fixed” to log a warning and continue anyway. Result: any request claiming to be Stripe was trusted. It also had no protection against Stripe’s automatic retries. What we changed: The signature is now checked against the exact raw request body; invalid or missing signatures get HTTP 400 and nothing is recorded. Each Stripe event ID is stored once (unique constraint) and the order is only marked paid on first delivery, inside a database transaction. Database errors return 500 so Stripe retries. Evidence: checks C1–C4. Before: unsigned and wrongly-signed events returned 200 and marked order #2 paid; a retry was recorded twice. After: forged events get 400, order stays pending; the genuine signed event marks the order paid exactly once.
Verification results
Before (customer baseline, commit 4b5dd2b + checks c2d6a5e)
| Check | What it proves | Result | Evidence |
|---|---|---|---|
| A1 | Anonymous visitor cannot read any user notes | ❌ FAIL | anon saw 2 note(s): Alice private, Bob private |
| A2 | Anonymous visitor cannot read orders | ❌ FAIL | anon saw 2 order(s) |
| A3 | Anonymous visitor cannot insert a note as another user | ❌ FAIL | HTTP 201 |
| A4 | Anonymous visitor cannot mark an order paid | ❌ FAIL | rows updated: 1 |
| A5 | Signed-in Alice sees only her own notes (not Bob’s) | ❌ FAIL | alice saw: Alice private, Bob private, pwned |
| A6 | Signed-in Alice can still create her own note (no regression) | ✅ PASS | HTTP 201 |
| A7 | Signed-in Alice cannot write a note into Bob’s account | ❌ FAIL | HTTP 201 |
| B1 | No Supabase service-role key in built bundle | ❌ FAIL | JWTs in bundle: service_role |
| B2 | No Stripe secret/restricted key or webhook secret in built bundle | ❌ FAIL | found: sk_test_FAKE_rescuelab_51NotARealKey000000000000 |
| B3 | No secret env vars exposed via VITE_ prefix in .env / source | ❌ FAIL | found: VITE_SUPABASE_SERVICE_ROLE_KEY, VITE_STRIPE_SECRET_KEY |
| C1 | Forged event with NO signature is rejected and order stays unpaid | ❌ FAIL | HTTP 200, order#2 status=paid |
| C2 | Event signed with the WRONG secret is rejected | ❌ FAIL | HTTP 200, order#2 status=paid |
| C3 | Genuine signed event is accepted and order is marked paid | ✅ PASS | HTTP 200, order#1 status=paid |
| C4 | Stripe retry of the same event is not recorded twice | ❌ FAIL | HTTP 200, recorded 2x |
After (branch rescue/fix-security, commit b3fe5f9)
| Check | What it proves | Result | Evidence |
|---|---|---|---|
| A1 | Anonymous visitor cannot read any user notes | ✅ PASS | anon saw 0 note(s) |
| A2 | Anonymous visitor cannot read orders | ✅ PASS | anon saw 0 order(s) |
| A3 | Anonymous visitor cannot insert a note as another user | ✅ PASS | HTTP 400 |
| A4 | Anonymous visitor cannot mark an order paid | ✅ PASS | rows updated: 0 |
| A5 | Signed-in Alice sees only her own notes (not Bob’s) | ✅ PASS | alice saw: Alice private |
| A6 | Signed-in Alice can still create her own note (no regression) | ✅ PASS | HTTP 201 |
| A7 | Signed-in Alice cannot write a note into Bob’s account | ✅ PASS | HTTP 400 |
| B1 | No Supabase service-role key in built bundle | ✅ PASS | JWTs in bundle: anon |
| B2 | No Stripe secret/restricted key or webhook secret in built bundle | ✅ PASS | none found |
| B3 | No secret env vars exposed via VITE_ prefix in .env / source | ✅ PASS | none |
| C1 | Forged event with NO signature is rejected and order stays unpaid | ✅ PASS | HTTP 400, order#2 status=pending |
| C2 | Event signed with the WRONG secret is rejected | ✅ PASS | HTTP 400, order#2 status=pending |
| C3 | Genuine signed event is accepted and order is marked paid | ✅ PASS | HTTP 200, order#1 status=paid |
| C4 | Stripe retry of the same event is not recorded twice | ✅ PASS | HTTP 200, recorded 1x |
Note on C3 “before”: the genuine event was recorded, but only because signature checking was effectively off — the same code also accepted forged events (C1, C2).
How to reproduce
cd app && npm install
node checks/run-all.mjs # resets the local DB, builds the bundle, runs all 14 checks; exit 0 = all pass
git diff main rescue/fix-security # every change we made
Not covered by this engagement
Authentication flow review, other tables/functions/storage buckets beyond those listed, rate limiting, and anything in your live Supabase/Stripe dashboards that isn’t represented in the code (e.g. policies created by clicking in the dashboard). We recommend a quick dashboard check after deploy.