DEV Community

kh.vibecoding
kh.vibecoding

Posted on Originally published at habr.com AI-assisted

Five Layers of Protection for Payments and AI Requests

Ask an assistant to "build payment processing" and you get code that works. Click the button, pay, get access. Tests pass, the demo looks convincing to the client. Then someone opens devtools and pays a dollar instead of a hundred. Here is what an AI assistant leaves broken in payment handling by default, on a real client project, and what we build around payments and AI requests so it does not happen.

Hole one: the price comes from the client

Here is what gets generated by default if you simply ask for "payment processing":

// do not do this
app.post('/checkout', async (req, res) => {
  const { productId, amount } = req.body;        // amount came from the browser
  const session = await psp.createSession({ amount, currency: 'usd' });
  res.json({ url: session.url });
});
Enter fullscreen mode Exit fullscreen mode

It looks logical enough: the frontend knows the price, so it sends it. But the frontend runs on the buyer's machine, and it takes one line in the browser console to edit. amount: 10000 becomes amount: 100, the payment provider happily charges a dollar, and the product ships.

The fix: the client sends only the id of what it is buying, and the server looks up the price itself.

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 calling the PSP: we reconcile the webhook 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 });
});
Enter fullscreen mode Exit fullscreen mode

Three lines of difference. But as long as the amount comes from req.body, any checks further down the code are pointless.

Hole two: the "thank you" page confirms the payment

The second common pattern: the user returns from the payment provider to /success, and access is granted right there. This breaks both ways. Close the tab right after the charge and the money is gone but access never gets granted, and you never find out. Or open /success directly, skip the payment entirely, and get access for free.

The only source of truth for a payment is the provider's webhook. And receiving it is not enough — you have to verify it: signature, timestamp, idempotency, and the order amount.

const crypto = require('crypto');
// express.raw is essential here: the signature is computed over 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 intercepted once should not be replayable 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. Plain === leaks timing information:
  //    the signature can be brute-forced byte by byte from the 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 a subscription three times over.
  if (await events.exists(event.id)) return res.sendStatus(200);
  // 4. Reconcile the amount against our own order, never trust the event alone
  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);
});
Enter fullscreen mode Exit fullscreen mode

By default, an assistant does, at best, the first of these five checks — signature verification. Idempotency, constant-time comparison, and amount reconciliation have to be asked for explicitly, one by one. Neither hole gets caught by ordinary testing: tests check "paid → got access" and "did not pay → no access", and an attacker is interested in the third path nobody on the review side thought to check.

Why the model defaults to this

I am not a senior engineer — I build products with AI, and the first thing that taught me is that the most dangerous thing is not bad code, it is code that looks like it works.

A model reproduces the most common pattern from what it was trained on, and in tutorials and examples the check is almost always on the client — it is shorter and easier to demonstrate. It implements what is visible in the interface, because the interface is what you described to it.

So for critical features — payments, access, other people's data — my first request is never about code:

"Don't write code yet. Look at the integration docs, the security standards, and the common vulnerabilities (especially price tampering and payment validation). Give me 2–3 ways to do this safely, list the trade-offs of each, and tell me which you recommend and why."

The holes get closed before any code exists — reworking the architecture afterward costs ten times as much. And the side effect turned out to matter more than the main one: you start actually understanding how your own product works under the hood, and you choose the approach deliberately. You do not need this for every button. For payments, it is not optional.

Attacking your own product before handover

Before handing off a project with payments, I open a clean session — not the context the code was written in — give the agents access to the result, and one task: bypass the payment.

The clean session is essential. An agent that knows "how it was meant to work" defends the design and explains why it is correct. An agent that only sees the code looks for a way to fool it.

That time, they found a bypass the first audit had missed.

On backups, separately: the habit started after a day an AI agent, tweaking something minor on a server, took down a client's live site. A snapshot taken before the work started saved it — three minutes, and the site was back. Since then, a backup is the first step of any task, no exceptions.

What we run around payments

Full protection does not exist. The goal is different: make an attack cost more than whatever it could gain.

Log everything. Every request, every operation. Looks paranoid right up until the first incident review.

Automatically block suspicious IPs — bots, spammers, odd request patterns.

Rate-limit spend per account — a sliding window on request count and on money spent:

// Count money as well as requests: 10 expensive calls
// hit the wallet harder than 1,000 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;
}
Enter fullscreen mode Exit fullscreen mode

This fires in two cases: either someone found a genuine vulnerability, or a stranger is running requests against a paid AI API on someone else's account — they found an endpoint that proxies calls to the model past the interface and its limits, and is using it as a free ChatGPT. Either way, the response is the same: stop first, investigate after.

Real-time alerts. Not "find out from the logs a week later" — see it now.

A kill switch. A script that cuts every external connection: no data, no API, and a notification to me.

It sounds dramatic for a product built by one person. But over-engineering this ten times over is still cheaper than getting it wrong once with a client's money.

The honest part

AI agents do not replace a pentest. They do not see the full infrastructure, do not know the business context, and confidently claim there is nothing wrong when they simply found nothing — every finding still needs a human check. It is a cheap first filter, not a security audit.

And the code above does not make a product unbreakable. It closes two specific places where mistakes happen most often.

More on how we build this at hikmah-labs.dev/en/ — a studio that builds web apps, bots, and AI agents, with payment and access security handled as a first-class part of the build, not an afterthought.

Top comments (0)