The cards hitting your checkout this week were never stolen. Someone took the first six or eight digits of a bank's card range, filled in the rest with a script, and pointed the output at your payment form to find out which combinations belong to a real person. That is a BIN attack, and you were picked because your checkout tells them the answer.
This is for the engineer looking at a dashboard full of invalid_number and do_not_honor declines that all start with the same digits: what the attacker is doing, how it differs from list-based card testing, and which counters catch it before your processor starts asking questions.
The number they are testing was never stolen
A card number has three parts. The leading six or eight digits are the Bank Identification Number, which names the issuer and product. The trailing digit is a checksum. Everything in between is the individual account identifier, and it is the only part the attacker has to guess.
The checksum is the Luhn algorithm from ISO/IEC 7812, a formula designed to catch mistyped digits. It was never a security control. Given any prefix, a loop can emit every account number whose check digit passes. Stripe's explainer describes the method plainly: attackers "generate complete card numbers by appending randomly generated digits to the BIN and calculating the appropriate check digit" with the Luhn algorithm.
The prefix is the easy part. BIN tables are published so payment forms can draw the right card logo, so any open BIN database hands the attacker a list of issuers, products, countries, and funding types to aim at. Visa moved to eight-digit issuing BINs with an April 2022 deadline for processors, so a current attack often walks a narrower range than the six-digit prefixes in older writeups.
A generated number is worthless until something with a live connection to the issuer says yes or no. Your checkout is that something. Every merchant with a card form and no per-card counters is a free oracle, and the attacker runs the same batch across dozens of them so no single merchant sees enough traffic to react.
Why a BIN attack looks different from card testing with a list
Ordinary card testing, the kind covered in our post on card testing, starts from a stolen list. Those cards were live when they were skimmed, and most still are. The attacker's problem is finding the dead ones so the survivors can be sold at a premium. The typical run approves far more often than it declines.
A BIN attack inverts that. A generated batch is overwhelmingly dead, because most account numbers in a range were never issued or have been closed. The attacker is searching for needles, and the haystack shows up in your logs as a decline ratio no legitimate traffic produces.
Three shapes follow:
- Prefix clustering. Hundreds of attempts share the same leading digits, because the batch was generated from one BIN. Real customers almost never cluster on one issuer product in one hour.
- Many fingerprints, one session. Generators walk the range in order or in stride. You cannot see the raw PAN through a tokenized integration, but your processor's card fingerprint changes with every attempt, so the observable signal is many distinct fingerprints from one session and one prefix.
- Repeated amounts. The same amount across every card, because the amount is a parameter of the script rather than a decision per card.
Visa's taxonomy draws the line the same way. Its Account Attack Intelligence sheet defines enumeration as "systematic initiating of CNP transactions to obtain valid payment credentials such as PAN, CVV2, Expiry Date" and says BIN attacks "typically focus on a single BIN range". The same sheet puts the fraud rate on enumerated accounts at 22 times that of regular accounts, and among enumerated accounts that did see fraud, a third saw it within five days of the enumeration transaction being approved.
List-based testing is caught mostly by amount and velocity, since the cards look fine individually. BIN attacks are caught by the decline pattern itself, if you are counting the right thing.
What your decline codes are telling the attacker
Stripe returns a specific decline_code on every failed charge, and the decline code reference distinguishes at least four outcomes that matter here:
| Code | What it tells the attacker |
|---|---|
invalid_number / incorrect_number | This account number does not exist. Discard it and move on. |
expired_card / invalid_expiry_month | The number exists. Only the expiry is wrong. Keep the PAN, iterate the date. |
incorrect_cvc | The number and expiry are right. Three digits left to guess. |
do_not_honor / generic_decline | The issuer saw a valid card and declined for its own reasons. The card is live. |
Read down the rows and the search space collapses. A tester who gets invalid_number spent one request on a dead candidate. A tester who gets incorrect_cvc has confirmed a live PAN and expiry and needs at most a thousand more requests for a working card.
The fix is old and unglamorous: return one generic message to the browser for every failure, and keep the specific code in your server logs. Stripe's guidance on fraudulent, stolen_card, and lost_card is "don't report more detailed information to your customer. Instead, present it in the same manner as generic_decline". Extend that rule to the whole table. "Your card was declined, please try another or contact your bank" covers every case a real customer will hit, and the attacker loses the feedback that made your checkout worth attacking.
Do this at the API boundary. If your React form receives the raw Stripe error object and merely hides the text, the attacker reads the JSON.
Who pays: the issuer, then you
The bank whose BIN is being walked sees the attack first. Its fraud team responds by tightening its risk stance on the merchants the enumeration is coming from. Stripe's card-testing documentation says it directly: a high decline rate "might damage the reputation of your business with card issuers and card networks, which makes all of your transactions appear riskier", and legitimate payments can keep declining even after card testing ceases.
So your first cost is silent. Real customers holding that bank's cards start getting do_not_honor on legitimate purchases, and you have no way to know why.
The second cost has a program name. The Visa Acquirer Monitoring Program tracks an enumeration ratio, enumerated authorizations divided by all authorizations, approved and declined, with a threshold of 20 percent alongside a floor of 300,000 enumerated transactions a month. The sentence before the numbers is what matters for you: Visa "requires acquirers to take proactive steps to prevent merchants from exceeding enumeration thresholds". Your acquirer, which for most of you means Stripe, is on the hook for your enumeration rate, and it behaves accordingly.
That is why the warning email arrives before you noticed anything. The successful $1 probes count separately: Mastercard's Excessive Chargeback Program triggers at 100 chargebacks and a 1.5 percent ratio in a month, which is where they end up if you do not refund them.
One founder on Hacker News, posting as tempaccount3333, described months as the test bench and noted that the attackers "simply get my public key (which I've rotated) and create their own checkout session". The attacker did not need the merchant's website at all, only its publishable key, which is in the page source by design.
The counters that catch it
Three pieces of standard advice fail against this attack.
"Add a CAPTCHA." Stripe's docs recommend it, and for a script hitting a plain HTML form it helps. But the attacker in the thread above never loaded the form. They took the publishable key and drove the Stripe API directly. A CAPTCHA on a page the attacker does not visit does nothing, and for those who do visit, headless browsers with solver services clear challenges for fractions of a cent per attempt.
"Just enable Radar." Radar is good, and Stripe blocked more than 20 million card testing attempts a day at the peak of the 2022 wave. But Radar rules on your account see your slice of the traffic. A BIN attack is sprayed across many merchants so each sees a few hundred attempts, below anything a per-account velocity rule notices. Our post on card testing through Stripe covers what those rules can and cannot key on.
"Block the BIN." The tempting move when 400 declines share a prefix is to reject that prefix. The prefix belongs to a bank. Every real customer of that bank is now unable to pay you, and they will leave without telling you. The attacker moves to the next BIN in their table within the hour.
What does work is counting the right thing, on your side, before the request reaches the processor.
Distinct cards per device and per session. This is the single strongest signal. A real customer presents one card, occasionally two. A tester presents dozens. The key has to be the device rather than the IP. Shared egress means one IP can legitimately be a whole office or a mobile carrier segment, and attackers rotate residential proxies cheaply, so a per-IP count both misses the attack and blocks real people. Our velocity checks post goes into the counter design, but the short version is three distinct card fingerprints from one device in an hour is review territory, and five is block.
Declines per prefix per hour. Count failed authorizations grouped by issuer prefix. Through a tokenized integration you never see the PAN, but Stripe exposes the six-digit BIN to Radar rules as card_bin. Your baseline for any single prefix is near zero. When it jumps to fifty in an hour, that is the earliest signal you have of a new batch starting.
Amount ladders repeating across cards. If the same amount, or the same sequence like $0.50 then $1.00 then $5.00, appears across many distinct card fingerprints in a short window, that is a script's parameter list. Real customers do not coordinate their basket totals.
$0 authorization runs. The attacker often prefers a SetupIntent over a PaymentIntent, because a SetupIntent validates the card without moving money, so nothing appears on a cardholder statement. Stripe's card-testing page names card setup as the preferred method because it "reduces the likelihood of card holders noticing and reporting the fraudulent activity". If you offer "add a payment method" anywhere, that endpoint needs the same per-device counters as your checkout, and stricter ones, since it has no price to make the ladder visible.
This is the shape of the problem I built Portreeve around. You call verdict() with event_type: "checkout_attempt" and the processor's card_fingerprint, card_funding, and the amount in minor units, and the engine runs the card probe patterns above plus a device-keyed counter fed by the browser fingerprint snippet. Two of the block-strength card rules read that device counter, so without the @portreeve/browser device token these shapes top out at review, which never blocks the user: the flow proceeds, the event lands in your review queue, and a later deny reaches your server as a signed webhook. Card numbers and CVVs never reach Portreeve, only the fingerprint and funding type your processor already gave you, and the free tier covers 1,000 screened events a month with no card required.
Blocking without blocking the bank
Once a counter trips, the question is what to reject. Reject the actor.
Block the device token. Block the session. If you have an account model, block the account and everything linked to it through shared identity keys. Do not block the BIN, and do not block the IP for more than a few minutes, because both are shared with people who were about to pay you.
The response to the blocked request should be the same generic decline every other failure gets. If the attacker can distinguish "you tripped a rate limit" from "this card is dead", they learn your threshold and tune the batch to stay under it.
Be careful with fail-closed logic on the counter store. If your Redis instance falls over and every checkout returns a generic decline, you have built the outage yourself. The counter should fail open with an alert. See handling verdicts for how degraded responses are treated; the same pattern applies to any homegrown counter.
Reporting it upstream and cleaning up
The attack leaves two kinds of residue: decisions your own rules should learn from, and small successful charges that become disputes if you leave them.
Refund the successes first. Any $0.50 or $1 charge from a device that tripped the distinct-cards counter is a probe hit, and the cardholder will dispute it when they find it. Stripe's checklist for an active attack puts refunding second, right after identifying the activity, because a refund costs the processing fee while a dispute costs that plus the dispute fee plus a mark against your monitoring ratios. In a 2023 Hacker News thread, a Stripe employee posting as dulse said the company would "refund any impacted transactions and waive the fees for those payments" for that merchant. Ask.
Then feed the outcome back. Whatever system made the decision should hear it was right, because the same device, session, and identity cluster will be back with a different BIN tomorrow. That is one confirmed_abuse call to the feedback endpoint if you took the route above, and the linked cluster is marked. If you built your own counters, the equivalent is promoting the device and session keys from a sliding window to a durable denylist.
Tell Stripe what you saw: the time window, the count of attempts, the prefix, the decline codes, and whether it was SetupIntent or PaymentIntent traffic. "One device presented 40 distinct cards" is a stronger case than "lots of declines".
Finally, watch the decline rate on real customers holding the affected prefix over the following week. A lift in do_not_honor on ordinary purchases from that bank's cards means the issuer tightened its stance on you. The only fix is time and a clean ratio, which is one more reason to catch the next batch at attempt three rather than attempt three hundred.
If you would rather not build and tune those counters yourself, create a free Portreeve account and screen your first 1,000 checkout attempts a month at no cost, no card needed.