Test Data
Luhn test cards: valid checksums that must never charge
On this page 0%
I once watched a staging checkout “succeed” against a card that someone had copied from a screenshot of a live dashboard. The last four matched a real customer. The form was happy because the number passed Luhn. The bank was not a test bank. The charge was a mistake we spent a day reversing. Checksums do not mean “play money.” They mean “this string is shaped like a PAN.”
I generate Luhn-valid test cards in the browser so QA can fill forms without borrowing a coworker’s plastic. The credit card generator is that fixture factory. This post is the algorithm, the difference between a test BIN and a real PAN, and why Stripe-style documented numbers are still safer than a number you invented and then forgot was invented.
What Luhn actually checks
ISO/IEC 7812 PANs carry a check digit. The Luhn algorithm (mod 10) is the usual one. Start from the right. The last digit is the check. Moving left, double every other digit. If doubling makes 10 or more, add the two digits of that result (or subtract 9, same thing). Sum everything. A valid number has a sum divisible by 10.
Worked example, because I always forget the “from the right” part when I code it tired. Take 79927398713, a textbook case. The check is 3. Doubling the odd positions from the right (skipping the check) and summing should land on a multiple of 10. If your function returns false on that string, you doubled from the left. I have shipped that bug. Tests that only used 16-digit Visa-looking numbers sometimes hid it, depending on length parity. Test odd lengths. Test 15-digit Amex shapes if you claim to support them.
Luhn catches single-digit typos and many transpositions of adjacent digits. It does not catch everything. It is not a signature. It is a cheap filter so a mistyped digit fails before you hit the network. That is why browsers and payment.js libraries run it client-side. That is also why a generator that only “looks random” will fail those libraries, and why people then paste a real card to “just get past the form.”
BINs versus PANs
The first 6 or 8 digits are the BIN (IIN). They hint at brand and issuer. The rest is account-shaped, then the check digit. If you randomize all 16 digits, you will get Luhn failures most of the time and weird brands the rest of the time. A generator worth using picks a BIN prefix for the brand you asked for, fills the middle, then sets the check digit so Luhn passes.
IIN ranges move. Visa is not “always 4 plus 15 digits” in every product, but 16-digit numbers starting with 4 are what most test forms expect. Mastercard 51-55 and 2221-2720. Amex 34 and 37 with 15 digits. Discover has a pile of prefixes. If your checkout only accepts Visa and Mastercard, do not generate Amex and then file a bug on the form. Generate the brand the suite needs.
A Luhn-valid number with a real-looking BIN is still not an authorization. Gateways will decline it, or worse, if it collides with a live PAN, they might do something you did not intend. Collision with a live card is unlikely if the middle is wide random, but “unlikely” is a rotten incident review. Prefer prefixes that payment processors document as test-only when you are hitting their API. Prefer obviously fake brands when you are only testing your own Luhn function.
Never put a generated PAN in a context that can charge. That includes a forgotten .env pointed at live Stripe keys. I have seen that. The test was green. The card was a generator output. The keys were not test keys. Money moved, then moved back, then someone wrote a wiki page titled “always check the dashboard URL.”
Stripe-style test numbers
Processors publish numbers that trigger specific outcomes: success, decline, 3DS, insufficient funds. Stripe’s 4242 4242 4242 4242 is famous enough to be a meme. It works in test mode because Stripe’s test environment special-cases it. It does not work on a live gateway. It also does not work on a competitor’s test mode unless they copied the same convention.
I use processor-documented numbers when the test must hit that processor. I use a local generator when the test must not depend on a vendor’s reserved PAN, or when I need many distinct numbers so a uniqueness constraint does not collapse the suite. A form that keys cards by PAN will treat 50 tests as one card if you always paste 4242.
Expiry and CVC are theater at the Luhn layer. Test modes often accept any future expiry and any 3 digits. Live modes do not. Do not teach QA that 12/34 and 123 always work. Put the real rules in the live-like staging environment, and keep Luhn fixtures for the field-validation layer.
Name and billing ZIP are not part of Luhn. Address verification is a gateway feature. For fake addresses next to fake cards, generate synthetic lines and keep them obviously fake. Pairing a Luhn PAN with a real customer address is how support tickets get confusing.
Generating your own
Algorithm: choose prefix, choose length, fill remaining digits except the last with a CSPRNG, compute the check digit that makes the Luhn sum 0 mod 10. Print with spaces every four digits if you like. Store compact in fixtures. The same compact-versus-display split as IBANs. I wrote about checksum generation in that world in IBAN mod-97. Different modulus. Same idea: compute the missing check so the validator smiles.
Do not copy a PAN from production “just this once.” PCI exists because people did that. Logs, error trackers, and chat screenshots will retain it. Generated numbers still look like PANs to DLP tools. Mark fixtures as test data. If your scanner flags them, good. Teach the scanner the test BIN list rather than pasting live cards to “look more realistic.”
I generate in the page so the digits never hit our servers. Same story as the rest of Randomly. If you need a bank account shape for a US ACH form next to the card form, that is the routing number generator, not a card. Do not stuff a routing number into a PAN field to skip validation. You will get a Luhn error and a useless stack trace.
What QA gets wrong
Using one card for every test, then wondering why “save card” is flaky. Uniqueness is a property of your app, not of Luhn.
Using a number that fails Luhn to test the happy path. The client library will stop you. Test the error path with a deliberate bad check digit. Test the happy path with a good one. Those are two tests.
Putting generated PANs in public issue trackers. GitHub is a search engine. I treat card-shaped strings like other PII-shaped fixtures: local files, redacted screenshots, rotate when a demo leaks.
Assuming Amex 15-digit Luhn is the same loop without testing it. Off-by-one in the doubling walk loves to hide in length.
Writing a custom Luhn in the frontend and a different Luhn in the backend. They will disagree on a number that ends with 0. I keep one implementation and import it. If I cannot share code, I share test vectors: a handful of known-good and known-bad strings, including the Wikipedia examples, not only generator output. Shared bugs across generator and validator are a closed loop. I said the same thing about IBAN check digits. It keeps happening.
PCI and the honest scope
If you never send PANs to your server, your PCI scope shrinks. That is the point of hosted fields and tokens. Test data does not give you permission to log live PANs “because we already log test ones.” Train the distinction. Test PANs are still PAN-shaped. Some log pipelines tokenize anything that matches \d{13,19}. That is annoying for fixtures and correct for production.
I do not generate CVV as if it were a second checksum. CVV is not Luhn. It is a secret the issuer computes. Test modes accept dummy values. If your app stores CVV, you have a bigger problem than this blog post.
For identity fields on the same checkout, keep SSN-shaped values format-only. Cards, routings, SSNs, and IBANs in one fixture file is a magnet for a mistaken production import. Label the file synthetic. Put it in testdata/. Do not name it customers.csv.
I still use 4242 when I am in Stripe test mode and I need their documented decline reasons. I use our generator when I am testing our form, our masking, our last-four display, and our “does Luhn run before submit” behavior. Two tools, two jobs. Mixing them is how you file a ticket that says “Stripe is down” because you pasted a random Luhn Visa into live mode.
Frequently asked questions
Does a Luhn pass mean the card can be charged?
No. It means the digits are internally consistent. Charging needs an issuer, a gateway, and usually a person who will be angry.
Why not always use 4242 4242 4242 4242?
Fine in Stripe test mode. Bad when you need unique PANs, other brands, or a processor that does not treat 4242 as magic.
Can I use generated cards on a real terminal?
No. Do not. If a terminal accepts it, you have a worse problem than fixtures.
Should the generator produce a matching CVV and expiry?
It can emit plausible fields for forms. They are not cryptographically tied to the PAN. Treat them as form filler, not as an issuer blob.