Portreeve
explainer · Updated 7 Sept 202613 min read

Card testing on Stripe: how the attack works and how to stop it

Card testing on Stripe explained from the attacker's side: why your checkout is the target, what declines and disputes cost, and what to change tonight.

If you are looking for Stripe's test card numbers, this is not that page. This is about card testing on Stripe as an attack: someone is pushing thousands of stolen cards through your checkout to find out which ones still work, and your dashboard has turned into a wall of declines. You probably got Stripe's warning email this morning, or saw a spike of 402 errors in your logs last night.

The attack is cheap to run, your site was chosen because it was the softest target the script found, and most of the advice you will read tonight costs you customers. Here is how it works, what it costs, and what changes it.

From the carder's side

Start with the economics of the person doing this to you, because the defense follows from them.

Stolen card data arrives in bulk, from phishing kits, skimmed checkout pages, and breach dumps. On the carding shops Rapid7 surveyed, a record runs $4 to $25 on one marketplace and $17 to $49 on another, by the perceived quality of the data. Most of a fresh dump is already dead, cancelled or reissued before the list was sold. A list of 10,000 numbers might hold a few hundred live ones, and the buyer cannot tell which without asking a bank.

That is what a card checker does. It asks a bank. It cannot ask directly, so it asks through a merchant with a card form connected to a processor. Stripe's card testing guide puts it plainly: fraudsters "use scripts to test a large amount of card information at once, and collect 3DS or issuer responses to validate which card information is valid," then "cash them with merchants or resell confirmed cards on the dark web." A validated card is worth many times an unvalidated one. Your checkout is the validation step.

Two probe shapes dominate.

The first is the $0 authorization. When a card is saved through a SetupIntent, Stripe "may send a request to the issuing bank for either a $0, $1, or similar authorization to verify that the card is valid," per Stripe's support page on $1 pending charges. No money moves. The cardholder sees nothing, or a pending hold that disappears. The issuer's approve or decline is the entire payload the attacker wanted. Stripe calls card setup "a method preferred by fraudulent actors" for exactly that reason: it does not show up on statements, so nobody reports it.

The second is the small charge. A $1 or $2 PaymentIntent, or the cheapest thing you sell. Success means the card is live and has a balance. The cardholder might notice three weeks later and dispute it, by which time the card has been resold or drained elsewhere.

Why you, and not Amazon? The script does not care who you are. Carders find checkouts the way spammers find comment forms: by crawling for pages that load Stripe.js and pulling the pk_live_ publishable key. Stripe's guide warns that "card testers can use your publishable key and use it to retry a large number of payments on your website." That key is public by design, cannot be expired, and can create tokens or PaymentMethods from payment details, per Stripe's key docs.

The scale is industrial. Radar blocked "more than 20 million card testing attempts per day" at the peak of the 2022 wave, and Stripe reported in January 2025 that successful attacks had since fallen 80% while global fraud rates rose 11%. Your night of 5,000 declines is what the residual looks like from inside one account.

What it costs you

The declines are not free, and the successes are worse.

Stripe's guide says "card testing activity can result in additional fees, such as authorization fees for custom pricing plans, and dispute fees." If you are on interchange-plus or any negotiated plan, every authorization request, approved or not, is a billable event. A checker script is a metered bill someone else is running up on your account.

Then there are the probes that succeed. Each $1 charge that lands is a real transaction with a real cardholder who did not authorize it, and some fraction become disputes. In the US, Stripe charges a $15 dispute received fee it never returns, plus $15 more "for each dispute you respond to manually," refunded only if you win, per Stripe's pricing page. In the euro area both are €20. A $1 fraudulent charge that reaches dispute costs $15 at minimum, and $30 if you counter by hand and lose. Smart Disputes is cheaper at these amounts: no manual counter fee, and 30% of the disputed amount billed only when you win.

The slower cost is your decline rate. Stripe warns that "a high decline rate might damage the reputation of your business with card issuers and card networks," which "can result in an increased decline rate for legitimate payments, even after card testing ceases." Enough disputes and you are enrolled in the card network monitoring programs, which carry monthly fines and can end with the network refusing to process your payments.

What Stripe Radar already does about card testing, and where it stops

Radar screens every transaction with default rules, and when Stripe detects an attack on a Checkout or Payment Element integration it applies "rate limiters, AI models, CAPTCHA triggers, ongoing reviews" and marks the results Blocked by Stripe. You get the warning email when its models notice the spike. That is the ceiling of the free protection, and how high it sits depends on what you send. Stripe's guide says "the success of the Stripe controls depends on your integration and what risk factors you send to us." A direct PaymentIntent integration that omits IP, email, name, and billing address gives Radar very little to key on.

Hosted Payment Links are a different problem: a URL anyone can hit, with no session and no server of yours in the loop, so your only levers are Radar rules and turning the link off.

SetupIntents are the third gap. Radar attributes with _charges_ in the name "include only card charge data"; the _transactions_ variants count charges and SetupIntents together, per the supported attributes reference. A rule like Block if :blocked_charges_per_ip_address_hourly: > 1 from Stripe's Radar rules 101 will never fire on a $0 setup probe, because there is no charge. If your product saves a card at trial start, you need the _transactions_ counters or you have no velocity rule at all on the endpoint the attacker prefers.

Those counters also cost money, and the plan names changed. What was Radar for Fraud Teams is now Radar Plus and Radar Pro; custom rules start at Plus, and the Lite and Standard tiers get card testing prevention with no rule editor. With Plus you can write rules on attributes like card_count_for_ip_address_hourly. Those counters cap at 25, and all of them count only what Stripe has already seen on your account. But they key on IP and card, and Radar's rule language still exposes no browser or device fingerprint attribute of any kind. A checker rotating through residential proxies presents a new IP every few requests, and every stolen card is by definition one Stripe has not seen on your account before. Radar Pro's 2026 abuse tier (free trial, multi-account, bot abuse) aims at the signup rather than the card form, and does not change that.

3D Secure shifts liability, and it does not stop the probe. Stripe's own description of the attack says testers "collect 3DS or issuer responses," so a 3DS challenge is itself a validation signal. Forcing 3DS on every new card also costs you conversions on cards that support it badly.

The advice that gets it wrong

Every thread about this converges on the same four suggestions. Three of them hurt you.

Add a CAPTCHA to the pay button. If you use Payment Element or Checkout, Stripe already did: its docs describe "automated CAPTCHA based protection" you have to ask Support to turn off. Bolting your own on top is where this goes wrong. On HN, whakim suggested "adding a CAPTCHA when requests exceed a certain threshold," which is the right shape. The always-on version puts a puzzle between a paying customer and the pay button, and the person running the checker does not solve puzzles. They pay a solving service a fraction of a cent per challenge, or they skip your form and use the publishable key directly. A CAPTCHA on the form does nothing about API calls that never load the form.

Require an account before checkout. The most expensive advice on the list. For a SaaS or API product, forced signup before payment cuts conversion, and it does not stop card testing. It moves it. The script now creates an account first, one extra HTTP request, then saves a card on it. You have added friction for humans and a signup spam problem for yourself.

Block countries. Radar makes this a one-line rule and it feels decisive. The cards are issued everywhere and the proxies exit everywhere. Blocking Nigeria, or Vietnam, or Brazil catches the least sophisticated attacker for one afternoon and permanently loses you every legitimate customer there. If you sell in the EU, the Geo-blocking Regulation restricts blocking EU customers by location anyway.

Turn off card-on-file. Removing SetupIntents removes the $0 probe surface, and if you never needed saved cards, fine. Most subscription products do. Ripping out trial-with-card to stop a checker is amputating a feature to treat a rash.

The one that survives is rate limiting, and the interesting question is what to key it on.

What stops card testing

A card tester's traffic has a shape that legitimate checkout traffic never has. The defense is to count that shape on the right keys.

Distinct cards per device. A real customer uses one card at checkout. Maybe two, if the first is declined. A checker uses hundreds. On HN, rameerez described "400+ cards and 40+ names under one single IP." Count the distinct card fingerprints seen from one browser fingerprint in an hour. Three is unusual. Five is not a customer. This separates the attack from the traffic more cleanly than anything else, and Stripe cannot compute it for you, because Stripe does not see your device.

Velocity per device and per card, with different thresholds. A device that produced ten authorization attempts in five minutes is a script. A card attempted on three accounts in an hour is a stolen card. Different signals, different confidence, different thresholds, and the device counter is the one allowed to block.

Probe ladders. Testers do not send random amounts. They send $0 setups, or $1, $1, $1, or your cheapest SKU on repeat. A run of small or zero-amount authorizations from one device with rotating cards has almost no legitimate explanation. Match the ladder, not the individual amount, and you avoid blocking the customer who genuinely bought your $1 add-on.

Per-IP counts review, never block. This is where most homegrown defenses go wrong. A university, a corporate VPN, or a mobile carrier's CGNAT puts thousands of real people behind one address. If your per-IP rule blocks after five attempts an hour, you block a whole office on the day two people sign up. The IP should raise the score and send the event to a review queue. The device fingerprint, per-browser rather than per-network, is the key that can carry a block.

Fail open. Whatever sits in front of your checkout must not be able to break your checkout. If the fraud check times out, the payment proceeds with a flag. A defense that adds a new outage mode to the revenue path is worse than the attack on most nights.

This is the shape I built Portreeve around, because I got tired of rebuilding it per project. You call it once at checkout_attempt with the Stripe card fingerprint and a device token, and it returns allow, review, or block with reason codes in under 100 ms, from per-device and per-card velocity, distinct-cards-per-device, and small-amount and $0 probe ladders. The browser fingerprint snippet is what makes device-keyed blocking possible; without it the card-testing shapes top out at review, and IP alone never blocks. The SDK fails open by default, returning allow with degraded: true on timeout.

Whether you use that or write it yourself, the server-side call looks the same. Check before you create the intent, not after:

const pm = await stripe.paymentMethods.retrieve(paymentMethodId);

const result = await portreeve.verdict({
  event_type: "checkout_attempt",
  external_user_id: userId,
  ip: req.ip,
  device_token: req.body.device_token,
  payment: {
    card_fingerprint: pm.card?.fingerprint,
    card_funding: pm.card?.funding,
    amount: 100, // minor units
    currency: "usd",
  },
});

if (result.verdict === "block") {
  return res.status(402).json({ error: "card_declined" }); // generic on purpose
}

const intent = await stripe.paymentIntents.create({
  amount: 100,
  currency: "usd",
  payment_method: paymentMethodId,
  confirm: true,
  metadata: { portreeve_event_id: result.id ?? "" },
});

How you handle review is the whole false-positive story. The flow proceeds, the event lands in a queue, and if it later turns out to be abuse you revoke via a signed webhook. The engine can flag aggressively because review never shows a real customer an error. The verdict handling reference covers the states and the reason codes name the counter that tripped.

Return a generic decline to blocked requests. Do not tell the script why. A distinguishable block response is a free oracle for tuning the next run.

Tonight

In order of leverage, assuming the attack is live and you have a few hours.

  1. Refund every probe that succeeded. Stripe's guide says to refund suspicious low-amount payments to avoid disputes, and it is right. A $15 dispute fee on a $1 charge is the most expensive line item in this whole episode.
  2. Roll your secret and restricted keys. If the attacker has sk_live_, they can create charges and SetupIntents without your site. Rotate from the API keys page, where the old key can expire now or survive up to seven days, then attach an access policy so the new key only works from your servers' IPs or ASN. Policies replaced the old IP restriction setting and can also refuse anonymous VPNs, residential proxies, and Tor exit nodes. On HN, a_simm reported that "cycling (all) the keys stopped it immediately."
  3. Turn on the Radar rules you have, and feed them. Enable the CVC and postal code block rules. On Radar Plus or Pro, add _transactions_ velocity rules so SetupIntents are covered, and set the IP rules to review rather than block. Send IP, email, name, and billing address on every intent, plus the Stripe.js advanced fraud signals. Every field you omit is a rule Radar skips.
  4. Count cards per device on your own server. Radar cannot see your device. Your own counters, keyed on payment_method.card.fingerprint and a browser fingerprint, are the durable fix. Start with distinct-cards-per-device and the small-amount ladder.
  5. Read your own logs. Filter for 402 responses and generic_decline outcomes. Group by user agent, IP prefix, and amount. The attack will be one obvious cluster, and that cluster is your first rule. If the traffic is hitting a hosted Payment Link, deactivate it and issue a new one.

Then tomorrow, keep the counters. Card testing does not stop when this attacker moves on. It stops when your checkout is no longer the cheapest place on the internet to ask a bank a question.

If you want the counters without building them, Portreeve's free tier screens 1,000 events a month, with no card required.

← Back to all posts