Ask an assistant to "add payments" and you get code that works. Click the button, pay, get access. The tests pass, the demo goes out to the client.
Then someone opens devtools and pays a dollar instead of a hundred.
Below are two mistakes the model reproduces almost every time, the corrected code for both, and what the author's setup puts around payments in the end.
Hole one: the price comes from the client
Here's what gets generated by default:
// don't do this
app.post('/checkout', async (req, res) => {
const { productId, amount } = req.body; // ← the amount came from the browser
const session = await psp.createSession({ amount, currency: 'usd' });
res.json({ url: session.url });
});
On the surface it's logical: the frontend knows the price, so it sends it. But the frontend is what sits on the buyer's machine, and it can be edited in one line in the console. amount: 10000 becomes amount: 100, the payment provider honestly charges a dollar, and the goods ship.
The right approach: the client sends only the identifier of what it's buying. The server takes the price from its own storage:
app.post('/checkout', requireAuth, async (req, res) => {
const product = await products.get(req.body.product_id);
if (!product) return res.sendStatus(404);
// record the order before hitting the payment provider: the webhook gets reconciled against it later
const order = await orders.create({
user_id: req.user.id,
product_id: product.id,
amount_cents: product.price_cents, // price comes from the database only
currency: product.currency,
status: 'pending',
});
const session = await psp.createSession({
amount: order.amount_cents,
currency: order.currency,
metadata: { order_id: order.id },
});
res.json({ url: session.url });
});
Three lines of difference. But as long as the amount comes from req.body, any checks earlier in the code are pointless.
Hole two: payment is confirmed by the "Thank you" page
The second common pattern: the user comes back from the payment provider to /success, and access is granted right there.
This breaks in both directions. Someone closes the tab right after the charge — the money is gone, access isn't granted, and you never learn about the payment. Or the reverse: they open /success directly, skipping payment, and get access for free.
The only source of truth about payment is the webhook from the provider. Receiving it is not enough; you have to verify it:
const crypto = require('crypto');
// express.raw is essential: the signature is computed from the raw body.
// After JSON.parse and re-serialization the bytes are already different.
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.get('X-Signature');
const ts = req.get('X-Timestamp');
if (!sig || !ts) return res.sendStatus(400);
// 1. Replay: a valid webhook captured once can't be replayed tomorrow
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(`${ts}.${req.body}`)
.digest('hex');
// 2. Constant-time comparison. A plain === leaks through timing:
// the signature can be guessed byte by byte from rejection speed.
const ok = sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok) return res.sendStatus(403);
const event = JSON.parse(req.body);
// 3. Idempotency: the provider retries until it gets a 200.
// Without this check one payment extends the subscription three times.
if (await events.exists(event.id)) return res.sendStatus(200);
// 4. Reconcile the amount against our own order, don't trust the event
const order = await orders.get(event.metadata.order_id);
if (!order ||
order.amount_cents !== event.amount_cents ||
order.currency !== event.currency) {
await alerts.send('payment_mismatch', { event });
return res.sendStatus(409);
}
await events.save(event.id);
await orders.markPaid(order.id);
res.sendStatus(200);
});
Of these five steps, the assistant does at most the first by default — signature verification. Idempotency, constant-time comparison, and amount reconciliation have to be requested separately, one by one.
Neither mistake shows up in normal testing, because tests cover two scenarios: paid — got access, didn't pay — no access. The attacker is after the third path, which the tester never considers.
Why the model works this way
The author is not a senior developer; they build products with AI — and the first thing that taught them: the most dangerous thing is not bad code, but code that looks like it works.
The model reproduces the most frequent pattern in what it learned from. In tutorials and examples the check is almost always on the client: it's shorter and easier to follow. It implements what's visible in the interface, because the interface is what you described to it.
So for critical features — payments, access control, other people's data — the author's first prompt is not about code:
Don't write code. Study the integration documentation, security standards, and common vulnerabilities (especially price tampering and payment validation). Give me 2–3 ways to do this safely, list the pros and cons of each, and tell me what you recommend and why.
The holes get closed before any code exists — reworking the architecture later costs ten times more. And the side effect turned out more important than the main one: you start to understand how your own product works under the hood, and you choose solutions deliberately.
You don't need this for every button. For payments, you do.
Attacking your own product before launch
Before shipping a payments project, the author opens a clean session — not the context the code was written in — gives the agents access to the result, and one task: bypass the payment.
The clean session is essential here. An agent that knows how it's all supposed to work defends the design: it explains why everything is correct. An agent that sees only the code looks for a way to fool it.
That time they found a bypass the first audit had missed.
A word on backups: the habit appeared after the day an AI agent, while fixing a small thing on a server, took down a client's production site. A copy taken before the work started saved it — three minutes, and the site was back. Since then, a backup has been the first step of any task for the author.
What sits around payments
Complete protection is impossible. The goal is different: make an attack cost more than what it can get.
- Log everything. Every request, every operation. It looks like paranoia right up until the first incident review.
- Automatic blocking of suspicious IPs — bots, spammers, strange request patterns.
- Per-account spend control — a sliding window over request count and money spent:
// Count not just requests but money: 10 expensive requests
// hurt the wallet more than 1000 cheap ones.
async function guard(accountId, costCents) {
const WINDOW = 3600;
const now = Math.floor(Date.now() / 1000);
const key = `spend:${accountId}`;
await redis.zremrangebyscore(key, 0, now - WINDOW);
await redis.zadd(key, now, `${now}:${crypto.randomUUID()}:${costCents}`);
await redis.expire(key, WINDOW);
const entries = await redis.zrange(key, 0, -1);
const requests = entries.length;
const spent = entries.reduce((s, e) => s + Number(e.split(':')[2]), 0);
const limit = await limits.get(accountId);
if (requests > limit.requests_per_hour || spent > limit.cents_per_hour) {
await accounts.block(accountId, 'anomaly');
await alerts.send('account_blocked', { accountId, requests, spent });
return false;
}
return true;
}
This fires in two cases: either someone found a vulnerability, or an outsider is running requests against a paid AI API at the author's expense — they found an endpoint that proxies requests to the model, bypassing the interface and the limits, and use it as a free ChatGPT. In both cases: stop first, investigate after.
- Real-time alerts. Not "find out from the logs a week later", but see it now.
- Emergency kill switch. A script that cuts all external connections: no data, no API, and a notification to the author.
That sounds dramatic for a product built by one person. But overprotecting ten times is cheaper than messing up a client's money once.
The honest part
AI agents don't replace a pentest. They don't see the whole infrastructure, don't know the business context, and confidently lie when they've found nothing — every finding has to be re-checked by hand. It's a cheap first filter, not a security audit.
And the code above doesn't make the product invulnerable. It closes two specific spots where mistakes happen most often.