← All docs

Protecting checkout

Bots attack a checkout in two ways, and neither needs to buy anything:

  • Card testing. A list of stolen card numbers, run through small payments to learn which are live — the live ones are sold on or spent elsewhere. You pay the processor's fee on every attempt, your decline rate climbs, and a processor that sees enough of it puts your account under review.
  • Card cracking. One stolen card number without its expiry date or security code, guessed one payment at a time.

Both are invisible to a fraud check that looks at one payment at a time: each attempt is a small, ordinary-looking charge. They are visible from the outside, across payments — which is what an assessment with a card sees.

What is being watched

  • One address, many cards. A person pays with one card, or tries a second and a third after a decline. Four cards from one address in ten minutes is a reason (velocity:cardsFromAddress, 30); eight is a challenge on its own (60). An IPv6 source is counted across its /48 too, at sixteen times those (velocity:cardsFromNetwork): a fresh /64 of a free /48 for each card is one card an address. A /48 the agent ran from on twenty devices in the last hour is a crowd's, and not counted so.
  • One device, many cards. One browser behind a proxy pool is an address per card, and one device for all of them: counted by the session token's visitor wherever the payment comes from (velocity:cardsFromDevice), at the same marks. Seen from the address and the device alike, it is one list, counted once.
  • Declines. Card testing declines on nearly every card it tries. Five or more declines from an address, or from a device whatever its addresses, with almost no approvals beside them (velocity:cardDeclines) weigh 40, fifteen 60. An office paying suppliers, declined sometimes and approved far more often, says nothing.
  • One card, declined again and again. Its owner mistypes a security code once or twice; five declines in ten minutes is guessing (velocity:cardCracking, 40). From five addresses or more it is guessing through rotating proxies (velocity:addressesForCard, 25 more).
  • The client, as for every assessment: a script that names itself, a browser's name without a browser's headers, an address other sites condemned. A library with four cards is blocked.

A valid session token does not excuse any of it — save, on a crowd's address after a passed challenge, the address's own counts, never the device's: card testing is often driven through a real browser that ran the agent once.

What to send as card

An opaque identifier that is the same every time the same card number is used, and is not the number:

Processor Use
Stripe payment_method.card.fingerprint
Braintree credit_card.unique_number_identifier
Anything else A keyed hash of the number — HMAC-SHA-256 with a secret of your own

Never a plain hash of the number: there are few enough card numbers that an unkeyed hash can be reversed. And never the number itself. A card that holds a card number — 13 to 19 digits standing alone, grouped by spaces, tabs, dots, slashes, underscores or dashes or not at all, full-width digits included, passing the Luhn check, or such a number with an expiry run on — is refused with 400 CardNumberRefused before anything else happens, and the number is not echoed back. A fingerprint, a hex or base64 digest and a UUID are never mistaken for one. A numeric token shaped like a card number can be: if your processor's token is 16 digits, send a keyed hash of it instead.

What you send is digested again on arrival, per environment: neither the identifier nor any part of the card is stored.

From your server

Assess before you charge, then report what the processor said:

const verdict = await antibot.assess({
  ip: clientIp,
  headers: req.rawHeaders,
  method: "POST",
  url: fullUrl,
  sessionToken: req.cookies._abid,
  card: paymentMethod.card.fingerprint,
})
if (verdict.action === "block") return res.status(403).end()
if (verdict.action === "challenge") return askForThreeDSecure() // or send a browser to the agent

const payment = await chargeCard(paymentMethod)
await antibot.reportPaymentOutcome({
  ip: clientIp,
  card: paymentMethod.card.fingerprint,
  outcome: payment.status === "succeeded" ? "success" : "failure",
  sessionToken: req.cookies._abid ?? "", // whose payment it was: "" for none
})

A challenge on a payment has a natural answer that a person passes and a script does not: ask for 3-D Secure. The same calls exist in every server SDK (report_payment_outcome, ReportPaymentOutcome, reportPaymentOutcome, ReportPaymentOutcomeAsync).

The edge enforcer cannot do this part for you, deliberately: the card is in the payment request's body, and an edge that read it would be holding card data. Your server, which already talks to the processor, is the place.

Your own policy

The rule editor's Stop card testing template challenges an address once it has tried five cards, or been declined five times, in ten minutes — a challenge, because a carrier's address on a busy shop is many people each paying with their own card; they pass it, and the script testing cards does not, and are not asked again for the address's counts. On a crowd's address (ipInfo.crowd: a VPN's exit) the counts are everyone's, and the device's own cards and declines hold it instead:

card.present and ((not ipInfo.crowd and not session.challenged and (card.cardsFromAddress >= 5 or card.declinesFromAddress >= 5)) or card.cardsFromDevice >= 5 or card.declinesFromDevice >= 5)

Rules see card.cardsFromAddress, card.cardsFromNetwork, card.cardsFromDevice, card.addressesForCard, card.declinesFromAddress, card.approvalsFromAddress, card.declinesFromDevice, card.approvalsFromDevice and card.declinesForCard — see rules for requests without the agent.

Steps taken faster than a person

A checkout bot does not need to be fast at any one request: each is well formed, and the session behind it holds a cart, so your own checks pass it. What gives it away is the flow. A person takes a moment on every step — the page loads, they check the basket, type or confirm the delivery address, pick the card — and a bot that has the next request ready sends it the moment the last one is answered: cart, delivery and payment in under a second. Or it skips the pages it needs nothing from and posts the payment first.

Say which step each request is, on the assessment you already make for it:

const verdict = await antibot.assess({
  ip: clientIp,
  headers: req.rawHeaders,
  method: "POST",
  url: fullUrl,
  sessionToken: req.cookies._abid,
  flow: { name: "checkout", step: 3, minMs: 3000 }, // cart 1, delivery 2, payment 3
  card: paymentMethod.card.fingerprint,
})

Each step is read against the step before it, over the last hour, as the same visitor took it — by its receipt, from any address — or the same client: the address and the browser it says it is, so two people behind one office's address are two clients where their browsers differ, and a script sending one step with its receipt and the next without is still one. Two people on the same browser build behind one address, without the agent's receipt, are one client: one's step can read as the step before the other's. Where the agent runs, each has a receipt and is their own:

  • Rushed. Taken sooner after the step before than minMs (one second when you do not say), or with none before it but in a burst with the flow's other steps: flow:rushed, 25. A second rushed step within ten minutes is a challenge on its own (50): nobody clicks through two pages each in under a second. Set minMs to what your slowest honest customer still beats — a payment form typed by hand takes several seconds, a "Continue" with everything saved about one.
  • Skipped. A step past the first, with no step before it: flow:skipped,
    1. A reason only — a page reopened from history, or a phone that changed networks without a receipt, does that too.

A valid receipt excuses neither: a browser that ran the agent once can still be driven through the checkout by a script. The response says what was read (flow: { sincePreviousMs, skipped, rushed, rushedSteps }), and rules see flow.name, flow.step and flow.sincePreviousMs for anything finer:

flow.name == "checkout" and flow.step == 3 and flow.sincePreviousMs < 3000

The rule editor's Hold a checkout run faster than a person template is that rule, with a payment entered with no step before it held too (or flow.skipped, not asked again of whoever passed the challenge: the retry has still skipped it) — a challenge from the first rushed step, on the step that matters, where the scoring waits for the second.

The flow's name is kept only inside a keyed digest, for the hour.

With the edge in front, name the steps there instead and change nothing in your site: ANTIBOT_FLOWS="checkout=POST /cart/checkout, /checkout/delivery, /checkout/payment@3000" assesses each step as it is taken, and answers a challenge with the press-and-hold (the edge's flows).

Knowing it is happening

Edge traffic in the dashboard counts every assessment, the edge's and your server's alike, with its reasons — "Many cards from one address", "Declined payments from one address", "One card declined again and again" — and a wave of them opens an attack alert.