The Cold Read.
SAMPLE

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.

What you actually get

A teardown report, start to finish.

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.

TEARDOWN — FINDINGS REPORT SAMPLE · REDACTED
Product
B2B scheduling SaaS, ~11k lines
Built with
AI-assisted, roughly six weeks
Stack
Next.js App Router · Supabase · Vercel · Stripe
Read over
3 working days
4Critical
9High
23Medium
6 hrsTo safe

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.

Critical findings

Exploitable now, by anyone, without special tooling. These are the reason the report exists.

Critical C-01 · AUTHORISATION

The API trusts the user ID the browser sends it

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.ts
export 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.

Critical C-02 · SECRETS

The service key is in the client bundle

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.

Evidence — .env.local, lib/.ts
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.

Critical C-03 · DATABASE

Row level security is off on three tables

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.

Evidence
curl 'https://.supabase.co/rest/v1/customers' \
  -H "apikey: <the public anon key>"

→ 200 OK — 1,4 rows
The 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.

Critical C-04 · PAYMENTS

The Stripe webhook does not verify signatures

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.

Evidence — app/api/webhooks/stripe/route.ts
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.

High — a sample of the nine

Not exploitable today, but each one becomes an outage or an incident as you grow.

High H-03 · PERFORMANCE

The dashboard query has no index and runs in a loop

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.

The fix
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.

High H-07 · OPERATIONS

Nothing reports errors from production

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.

The fix order

What to do, in the order that removes the most risk per hour spent.

  1. This weekRotate the Supabase service keyC-02. The old key is public. Everything else is secondary to this — do it before you fix the code that leaked it.
  2. This weekAdd session checks to the six routesC-01. Ninety minutes, and it closes the most directly exploitable hole.
  3. This weekVerify the Stripe webhook signatureC-04. Thirty minutes, stops free subscriptions.
  4. This weekEnable RLS on the three tablesC-03. Do it in a branch with policies written first.
  5. This monthInstall error monitoringH-07. Cheap, and it makes everything after it measurable.
  6. This monthIndex and de-loop the dashboard queryH-03. The one users will feel.
  7. OngoingWork the 23 mediums opportunisticallyFix them when you are already editing the surrounding code. Do not schedule a sprint for them.

What I am not asking you to change

The part most audits leave out. Untidy is not the same as dangerous, and rewriting working code costs you weeks for nothing.

Find out what you shipped.

Three working days, £350 + VAT, and a report like this one about your own product.

See pricing