This is an illustration, not a real engagement. It is assembled from the failure modes I find most often in AI-built codebases. No client's code appears in it, no real product is described, and the counts are representative rather than measured. It exists so you can see the shape of what you would receive before you pay for one.
Every finding graded, evidenced, and paired with the fix. A prioritised order at the end, so you know what to do on Monday morning — and an explicit list of what to leave alone.
Verdict. The product works and the architecture is sound enough to keep. Four findings would expose customer data to anyone who opened the network tab, and all four are fixable in a single afternoon. Nothing here justifies a rewrite. Fix the criticals this week, the highs this month, and treat the mediums as a backlog you work through when you touch the surrounding code anyway.
Exploitable now, by anyone, without special tooling. These are the reason the report exists.
Six route handlers read an identifier from the request body and use it to select rows. The session is never consulted, so the value is whatever the caller typed. Changing one number in a request returns another customer's records.
Evidence — app/api//route.tsexport async function POST(req) {
const { userId, month } = await req.json()
const rows = await db
.from('bookings')
.select('*')
.eq('owner_id', userId) // ← supplied by the caller
return Response.json(rows)
}
The fix
export async function POST(req) {
const { month } = await req.json()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return new Response('Unauthorised', { status: 401 })
const rows = await db
.from('bookings')
.select('*')
.eq('owner_id', user.id) // ← from the verified session
return Response.json(rows)
}
Effort: ~90 minutes across all six routes. Do this first.
The Supabase service role key is exposed through a NEXT_PUBLIC_ variable. That prefix instructs Next.js to inline the value into JavaScript served to every visitor. The service role key bypasses row level security entirely — it is the master key to the database, published.
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi // found verbatim in the served bundle: curl -s https:///_next/static/chunks/main.js \ | grep -o 'eyJhbGciOi[A-Za-z0-9._-]*'The fix
SUPABASE_SERVICE_ROLE_KEY=… # server-only, no NEXT_PUBLIC_ // then rotate the key — it has been public and must be // treated as compromised, not merely hidden.
Effort: 20 minutes, plus a key rotation. The rotation is not optional — anyone who loaded the site holds the old key.
The bookings, customers and invoices tables have RLS disabled. Supabase exposes a REST endpoint for every table by default, so with the anon key — which is public by design — those tables are readable directly, bypassing the application entirely. The app's own permission checks are irrelevant to this route.
curl 'https://.supabase.co/rest/v1/customers' \ -H "apikey: <the public anon key>" → 200 OK — 1,4 rowsThe fix
alter table customers enable row level security; create policy "own rows only" on customers for select using ( auth.uid() = owner_id );
Effort: ~1 hour for three tables, including testing that the app still works with policies on. Test in a branch — enabling RLS without policies locks the app out of its own data.
The webhook handler parses the request body and acts on it without checking the Stripe-Signature header. The endpoint is public. Anyone who guesses the URL can post a forged checkout.session.completed event and be granted a paid subscription for free.
const event = await req.json() // ← no verification
if (event.type === 'checkout.session.completed') {
await grantSubscription(event.data.object.customer_email)
}
The fix
const sig = req.headers.get('stripe-signature')
const body = await req.text() // raw body, not parsed
let event
try {
event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET
)
} catch {
return new Response('Bad signature', { status: 400 })
}
Effort: 30 minutes. Note the raw body is required — parsing first breaks verification.
Not exploitable today, but each one becomes an outage or an incident as you grow.
The bookings list issues one query per row to fetch the customer name — 60 queries to draw one page — and bookings.owner_id has no index. At the current 1,400 rows the page takes 900ms. The cost grows with the product of both problems, so this degrades faster than it looks.
create index bookings_owner_id_idx on bookings (owner_id); -- and fetch in one round trip: select bookings.*, customers.name from bookings join customers on customers.id = bookings.customer_id where bookings.owner_id = $1;
Effort: ~2 hours. Measured effect in the sample: 900ms → 40ms.
There is no error tracking. Server exceptions surface as a generic 500 and are visible only in Vercel's logs, which nobody is watching. You will hear about failures from customers, days late, without a stack trace.
This one is worth fixing before the performance work: you cannot tell whether the other fixes helped if you cannot see what is breaking.
Effort: ~1 hour to install and verify a test exception arrives.
What to do, in the order that removes the most risk per hour spent.
The part most audits leave out. Untidy is not the same as dangerous, and rewriting working code costs you weeks for nothing.
Three working days, £350 + VAT, and a report like this one about your own product.
See pricing