adev.build
Adem replies within the hourStart a project

What a rescue audit finds.

A sample report on a small booking app: customers book a class, pay with Stripe, and an admin page lists the orders. It works in a demo. In production it would leak every customer's details and take payments it never checked.

The app was written on purpose with the mistakes AI app builders most often make. It isn't a client's code, and the keys in it are fake. How the audit works

criticalEvery customer's details can be read by anyone

supabase/schema.sql, line 13, together with lib/supabase.ts

-- Row level security was switched off because inserts were failing during the build.
alter table orders disable row level security;

What can go wrong

The app talks to the database straight from the browser, using a key that is public by design. With row level security off, that public key can read, change or delete every order, including each customer's email and phone number. Anyone who opens the browser's developer tools can do it in a minute.

The fix

Turn row level security back on. Add policies so a signed-in customer can only read their own orders, and move order creation to the server, where it uses a key the browser never sees.

criticalThe Stripe secret key is shipped to every visitor

.env.local, line 4, and app/api/orders/route.ts, line 4

NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_…

const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!);

What can go wrong

Anything prefixed NEXT_PUBLIC_ is bundled into the JavaScript sent to the browser. With the live secret key, anyone can issue refunds, read the customer list and create charges on the account.

The fix

Roll the key in the Stripe dashboard today. Store the new one as STRIPE_SECRET_KEY, without the public prefix, and only use it in server code.

criticalAny order can be marked as paid without paying

app/checkout/success/page.tsx, lines 10–11

const orderId = params.get('order');
supabase.from('orders').update({ status: 'paid' }).eq('id', orderId);

What can go wrong

The order is marked paid by the browser when someone lands on the success page. Visiting /checkout/success?order=… with any order's ID marks it paid. No payment is involved.

The fix

Mark orders paid only from a Stripe webhook (checkout.session.completed) handled on the server, after checking Stripe's signature. The success page should only show what the server says.

highCustomers choose their own price

app/api/orders/route.ts, lines 12 and 17

amount_cents: body.amount,
…
unit_amount: body.amount

What can go wrong

The price comes from the request the browser sends. Changing one number in that request books a $90 class for 1 cent, and the checkout looks completely normal.

The fix

Look up the price on the server from the class ID, or use Stripe Price IDs created in the dashboard. Never accept an amount from the browser.

highThe admin page is only hidden, not protected

app/admin/page.tsx, lines 10–13

supabase.auth.getUser().then(({ data }) => {
  if (data.user?.email === process.env.NEXT_PUBLIC_ADMIN_EMAIL) setAllowed(true);
});
supabase.from('orders').select('*').then(({ data }) => setOrders(data ?? []));

What can go wrong

All orders are downloaded before the admin check runs, and the check itself happens in the browser, where it can be skipped. The data is already in the network tab of anyone who opens /admin.

The fix

Check the admin role on the server, store roles in the database rather than an environment variable, and let row level security decide who sees which rows.

mediumNothing checks what customers send

app/api/orders/route.ts, lines 7–13

const body = await req.json();
const { data: order } = await supabase.from('orders').insert({
  customer_email: body.email,
  customer_phone: body.phone,

What can go wrong

Empty, malformed or enormous values go straight into the database, and a script can create thousands of fake orders. That means junk data, inflated Stripe usage and a confused admin page.

The fix

Validate the request with a schema (for example zod), rate-limit the endpoint, and add a bot check such as Cloudflare Turnstile to the booking form.

mediumErrors are silently ignored

app/api/orders/route.ts, lines 8 and 18, and app/checkout/success/page.tsx, line 11

const { data: order } = await supabase.from('orders').insert({ … }).select().single();
…
success_url: `…/checkout/success?order=${order.id}`

What can go wrong

If the insert fails, order is undefined and the route crashes with an unhelpful 500. The customer sees a broken checkout and nobody is told. The success page also never checks whether its update worked.

The fix

Check every database and Stripe result, return a clear message to the customer, and send failures to an error tracker or a Slack alert.

lowThe thank-you page always says the class is booked

app/checkout/success/page.tsx, line 13

return <h1>Thanks! Your class is booked.</h1>;

What can go wrong

Customers whose payment failed, or who were sent the link, are told they are booked. That leads to no-shows, disputes and awkward emails.

The fix

Show the order's real status from the server, with a short wait while the Stripe webhook arrives.

How a real audit works

  1. You share the code and a test account

    Read access to the repository and the hosting dashboards. Nothing is changed during the audit.

  2. I check what an attacker would

    Database rules, exposed keys, payments, authentication and admin access, then reliability: errors, validation, backups.

  3. You get a report like this one

    Every finding with the code, the risk in plain words and the fix, ordered by what to do first.

  4. Then the fixes, at a fixed price

    Most AI-built apps are made safe in under a week, without a rewrite.

Is your AI-built app ready for real users?

Send me the link to your app. You'll get a fixed price for the audit and the fixes, within the hour.