DEV Community

Rahul S
Rahul S

Posted on

Card-Testing Bots Will Find Your Checkout the Day You Launch. Here's the Pre-Charge Filter That Stops Them.

The first time you ship a public create-payment-intent route, you assume the risk is someone paying with a stolen card. That happens, but it's not what wakes you up at 3am in week one. What wakes you up is a graph: a flat decline rate that suddenly spikes to 80%, thousands of $1.00 authorizations from cards you've never seen, all in a few minutes.

That's card testing. Fraudsters buy a dump of stolen card numbers and need to know which ones are still live before they try to actually cash them out somewhere else. Your brand-new checkout is a free oracle: fire a tiny authorization, read the response, sort the "approved" cards from the dead ones. Your store is just the validation step.

Why it slips past everything you already have

Here's the uncomfortable part. Every defense you reach for first doesn't fire, because each individual request is completely valid.

Amount tampering? No — they send a legitimate amount. Idempotency bug? No — every request is genuinely distinct. Webhook signature check? Never gets there; they don't care about fulfillment, they only care about the auth response. Stripe Radar? Radar is very good, but it's scoring the charge, and by the time a request reaches Radar you're already being billed per authorization attempt. Enough of those and your decline rate alone can dent your account standing with the card networks, independent of any single fraudulent charge succeeding.

The shape of the attack is the inverse of the bugs everyone writes about. The classic checklist — verify the webhook, don't trust client-side amounts, scope your keys — is all about a malformed request the server trusts too much. Card testing is a perfectly well-formed request that's still hostile. Correctness fixes don't touch it, because there's nothing incorrect to fix.

The cheap filter goes before the charge

The insight that makes this tractable: you don't have to detect a stolen card. You have to detect the pattern of a bot spraying your endpoint, and that pattern is visible before you ever call paymentIntents.create.

Card-testing waves look almost nothing like your real buyers at the network level:

  • They arrive from datacenter / hosting ranges (or a rotating residential-proxy pool), not consumer ISPs.
  • The associated emails are fresh throwaways with no prior session, no order history, no sending reputation.
  • They hit the create-intent route directly, often skipping the pages a real shopper loads first.

None of those are things a stolen card number tells you. They're things the connection and identity tell you. So the move is to score the source IP and email before you spend a Stripe call on them, and only let human-looking traffic reach the payment processor at all.

A pre-charge gate in Express

Here's a middleware that runs in front of the payment route. I'm using IPASIS here because it returns IP reputation and email risk in one call with sub-20ms latency, which matters when it sits on the hot path of checkout — but the shape is the same whatever you score with.

// middleware/preChargeGate.js
const RISK_API = "https://ipasis.com/api/v1/scan";

async function preChargeGate(req, res, next) {
  const { email } = req.body;
  const ip = (req.headers["x-forwarded-for"] || req.ip || "").split(",")[0].trim();

  try {
    const resp = await fetch(
      `${RISK_API}?ip=${encodeURIComponent(ip)}&email=${encodeURIComponent(email)}`,
      {
        headers: { Authorization: `Bearer ${process.env.RISK_KEY}` },
        // keep the timeout tight — this is on the checkout hot path
        signal: AbortSignal.timeout(1500),
      }
    );
    const data = await resp.json();

    // Trust score is 0-100; low means "looks automated / high risk"
    const looksAutomated =
      data.trust_score < 30 ||
      (data.ip?.datacenter && data.email?.disposable);

    if (looksAutomated) {
      // DON'T hard-error with a clear "declined" — that's the exact
      // signal the tester wants. Slow them down instead.
      return res.status(202).json({ status: "processing" });
    }

    req.risk = data;
    next();
  } catch (err) {
    // Fail OPEN. A risk API being slow must never block real revenue.
    console.warn("pre-charge risk check skipped:", err.message);
    next();
  }
}

module.exports = preChargeGate;
Enter fullscreen mode Exit fullscreen mode

Then the payment route only ever sees traffic that already cleared the gate:

const express = require("express");
const preChargeGate = require("./middleware/preChargeGate");
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);

const app = express();
app.use(express.json());

app.post("/api/create-intent", preChargeGate, async (req, res) => {
  const intent = await stripe.paymentIntents.create({
    amount: req.body.amount,   // still validate this server-side!
    currency: "usd",
    metadata: { risk_score: req.risk?.trust_score ?? "skipped" },
  });
  res.json({ clientSecret: intent.client_secret });
});
Enter fullscreen mode Exit fullscreen mode

Three things that are easy to get wrong

Don't return a clean decline. The whole point of card testing is reading your response. If a flagged request gets an instant, unambiguous "card declined," you've just given the bot a faster oracle. Return a soft 202/processing, add latency, or route to a challenge — anything that makes the endpoint expensive and low-signal to probe.

Fail open, always. This gate sits in front of money. If the risk API is slow or down, the correct behavior is to let the request through to Stripe, where Radar still has your back. Blocking real checkouts because a side service hiccuped is a worse outcome than a bit of card testing.

Score, don't hard-block. A single "datacenter IP" is not proof of fraud — plenty of legitimate users are on corporate VPNs. It's the combination (datacenter range + disposable email + no prior session + burst timing) that makes a confident call. Weight the signals; don't bet everything on one.

Test it before you wire anything

Before writing a line of integration code, it's worth eyeballing what these signals actually look like. You can paste any IP or email into ipasis.com/scan and see how it classifies — residential vs datacenter vs known proxy, disposable-email flag, reputation. The API docs show the full response shape, and the free tier (3,000 requests/month, no card required) is plenty to prototype a checkout gate.

The takeaway isn't "buy a fraud API." It's that the card-testing wave is the one attack on your checkout that your correctness fixes structurally cannot see, and the cheapest place to catch it is one layer up from the charge — on the connection and the identity, before you've spent a single processor call on a request that was never going to be a customer.

Top comments (0)