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.amountWhat 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
You share the code and a test account
Read access to the repository and the hosting dashboards. Nothing is changed during the audit.
I check what an attacker would
Database rules, exposed keys, payments, authentication and admin access, then reliability: errors, validation, backups.
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.
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.
