Luhn check
Run the checksum over the digits: node luhn.js 4242424242424242 prints sum 80, 80 mod 10 = 0 and consistent. That is the whole result. Luhn proves the digits agree with their own check digit and nothing more, so a form that answers "valid card" after this step is reporting something it never measured.
Why check this
Every checkout form runs this before it sends anything to a gateway, because it catches a mistyped digit without a network call and without an authorisation attempt that shows on the cardholder's statement. Testers run it on the form under test and on their own fixture files. The failure it prevents is a fixture with a transposed digit that fails in a sandbox for a reason nobody can reproduce.
Prerequisites
- Node 22, or any language with integer arithmetic. Nothing here contacts a card network.
- Save the checker as
luhn.js. It prints the column values so a wrong answer can be traced to a digit:
function columns(n) {
const out = [];
let double = false;
for (let i = n.length - 1; i >= 0; i -= 1) {
let d = Number(n[i]);
if (double) { d *= 2; if (d > 9) d -= 9; }
out.unshift(d);
double = !double;
}
return out;
}
const sum = (n) => columns(n).reduce((a, b) => a + b, 0);
const valid = (n) => sum(n) % 10 === 0;
const checkDigit = (prefix) => (10 - sum(prefix + '0') % 10) % 10;
for (const n of process.argv.slice(2)) {
const s = sum(n);
console.log(`number ${n}`);
console.log(`columns ${columns(n).join(' ')}`);
console.log(`sum ${s}, ${s} mod 10 = ${s % 10}`);
console.log(`verdict ${valid(n) ? 'consistent' : 'NOT consistent'}`);
if (!valid(n)) console.log(`would pass ${n.slice(0, -1)}${checkDigit(n.slice(0, -1))}`);
console.log('');
}
- Save the sweep as
sweep.js. It reads a file of numbers, one per line, and reports what the checksum catches:
const fs = require('node:fs');
const all = fs.readFileSync(process.argv[2], 'utf8').trim().split(/\r?\n/);
const luhn = (n) => {
let sum = 0, double = false;
for (let i = n.length - 1; i >= 0; i -= 1) {
let d = Number(n[i]);
if (double) { d *= 2; if (d > 9) d -= 9; }
sum += d; double = !double;
}
return sum % 10 === 0;
};
const bad = all.filter((c) => !luhn(c));
const cards = all.filter(luhn);
console.log(`published sandbox numbers ${all.length}`);
console.log(`Luhn consistent ${cards.length}`);
console.log(`Luhn inconsistent ${bad.length} ${bad.join(' ')}`);
let sub = 0, subMissed = 0, tr = 0, trMissed = 0;
const pairs = new Set();
for (const c of cards) {
for (let i = 0; i < c.length; i += 1) {
for (let d = 0; d <= 9; d += 1) {
if (String(d) === c[i]) continue;
sub += 1;
if (luhn(c.slice(0, i) + d + c.slice(i + 1))) subMissed += 1;
}
if (i < c.length - 1 && c[i] !== c[i + 1]) {
tr += 1;
if (luhn(c.slice(0, i) + c[i + 1] + c[i] + c.slice(i + 2))) {
trMissed += 1;
pairs.add([c[i], c[i + 1]].sort().join(' and '));
}
}
}
}
const pct = (a, b) => (((b - a) / b) * 100).toFixed(1);
console.log(`one digit mistyped ${sub} variants, ${subMissed} still pass (${pct(subMissed, sub)}% caught)`);
console.log(`two digits swapped ${tr} variants, ${trMissed} still pass (${pct(trMissed, tr)}% caught)`);
console.log(`swaps it never catches ${[...pairs].join(', ') || 'none'}`);
const checkDigit = (prefix) => {
let sum = 0, double = true;
for (let i = prefix.length - 1; i >= 0; i -= 1) {
let d = Number(prefix[i]);
if (double) { d *= 2; if (d > 9) d -= 9; }
sum += d; double = !double;
}
return String((10 - (sum % 10)) % 10);
};
const regen = cards.filter((c) => checkDigit(c.slice(0, -1)) === c.slice(-1)).length;
console.log(`check digit regenerated ${regen} of ${cards.length} match the published last digit`);
- A file
cards.txtwith one number per line. The run below used the 74 numbers that seven providers publish on their own testing pages, the list behind the checker above. Point it at your own fixtures once you have seen what the output means. - The algorithm is ISO/IEC 7812-1, annex B. Never put a real card number into any of this.
Steps
- Step 1.
Read the columns for a number that passes, one that fails, and one that is not a card at all.
node luhn.js 4242424242424242 4242424242424241 0000000000000000number 4242424242424242 columns 8 2 8 2 8 2 8 2 8 2 8 2 8 2 8 2 sum 80, 80 mod 10 = 0 verdict consistent number 4242424242424241 columns 8 2 8 2 8 2 8 2 8 2 8 2 8 2 8 1 sum 79, 79 mod 10 = 9 verdict NOT consistent would pass 4242424242424242 number 0000000000000000 columns 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 sum 0, 0 mod 10 = 0 verdict consistentEvery second digit from the right is doubled, and 9 is taken off anything over 9. The first two numbers differ in one digit and the sum moves by one. Sixteen zeros reach 0, which is a multiple of 10, so the checksum is satisfied by a number no bank ever issued.
- Step 2.
Sweep the fixture file. The script mutates every number in every position and reports what the checksum notices.
node sweep.js cards.txtpublished sandbox numbers 74 Luhn consistent 73 Luhn inconsistent 1 4242424242424241 one digit mistyped 10377 variants, 0 still pass (100.0% caught) two digits swapped 538 variants, 18 still pass (96.7% caught) swaps it never catches 0 and 9 check digit regenerated 73 of 73 match the published last digitOne published number fails on purpose. Stripe lists
4242424242424241so a test can exercise theincorrect_numberpath without a gateway call. The last line is how to test your own implementation: regenerate the check digit from the other digits and compare it with the published one. - Step 3.
Reproduce the gap the sweep found. Take a published number with a 0 next to a 9 and swap that pair.
node luhn.js 4000000000009995 4000000000090995number 4000000000009995 columns 8 0 0 0 0 0 0 0 0 0 0 0 9 9 9 5 sum 40, 40 mod 10 = 0 verdict consistent number 4000000000090995 columns 8 0 0 0 0 0 0 0 0 0 0 9 0 9 9 5 sum 40, 40 mod 10 = 0 verdict consistentBoth sums are 40. Doubling 0 gives 0 and doubling 9 gives 9, so the pair contributes the same total in either order. The first number is a Stripe
insufficient_fundscard and the second is not on any list. - Step 4.
Check a number at a length its brand never issues.
node luhn.js 42424242424242426number 42424242424242426 columns 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 6 sum 70, 70 mod 10 = 0 verdict consistentSeventeen digits beginning with 4. Visa issues 13, 16 and 19, so this is not a Visa and the checksum has no opinion about that. Length and issuer range are separate tests, and the checker above runs all three.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| sum mod 10 = 0 | The digits agree with the check digit | Report "the number is well formed". Not "valid card". |
| sum mod 10 is not 0 | One digit is wrong, or the number is a deliberate failure case | Read the would pass line to see which digit the checksum expected. |
| A fixture number fails | A digit was lost copying it out of a provider page | Replace it from the provider's page, not from a blog post. |
| Consistent and no provider publishes it | Possibly a real account number | Keep it out of the repository, the fixture and the bug report. |
| Consistent at a length the brand does not issue | Luhn passed, the length test has not run | Check length and issuer range separately, as step 4 shows. |
| The form says "valid card" | The message claims more than the check measured | Change the wording. The customer reads it as "this card will work". |
Thresholds
Common mistakes
What to check next
- Card expiry date validation: the other field on the same form, and the one with an off-by-one that no far-future test date reaches.
- Price tampering: a well formed number with an amount the client chose is still a broken checkout.
- Zero decimal currency: the amount beside the card number needs its own unit test.
- Sensitive data in logs: what happens to the digits after the form accepts them.
- Check a test card number: the checksum, the brand range, the length and which provider publishes the number, in one place.
FAQ
What is the Luhn checksum?
A check digit scheme from ISO/IEC 7812-1. Double every second digit from the right, subtract 9 from any result over 9, add everything up, and the total is a multiple of 10 when the digits are self-consistent. It runs on card numbers, IMEI numbers and several national identifiers.
Does passing Luhn validation mean the card is valid?
No. It means the digits agree with each other. Sixteen zeros pass, as step 1 shows. Whether an account exists, is open and will accept the amount is answered by the authorisation, and by nothing you can compute locally.
How is the Luhn check digit calculated?
Treat the last position as 0, run the same weighting over the digits in front of it, and the check digit is whatever brings the total to the next multiple of 10. The would pass line in step 1 prints it for a number that failed.
Why does Stripe publish a card number that fails Luhn?
So a test can reach the incorrect_number decline without a valid number. 4242424242424241 is 4242424242424242 with the check digit changed, and it is the one number of the 74 in step 2 that fails the sweep.
How do you test a payment flow with test cards?
Take the numbers from the provider's own testing page, because the same digits mean different things at different providers, and put the provider name in the fixture next to the number. The charge itself needs a sandbox account with that provider. Nothing on this page contacts one.
Verified
Verified by Maks Vernynode 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
- Checker: test-card Luhn validity, card brand, known sandbox test card numbers
- Payment gateway testing checklist
- All payments and webhooks checks
basic9 minpublished updated Maks Verny