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

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('');
}
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`);

Steps

  1. 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 0000000000000000
    
    number       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      consistent

    Every 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.

  2. Step 2.

    Sweep the fixture file. The script mutates every number in every position and reports what the checksum notices.

    node sweep.js cards.txt
    
    published 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 digit

    One published number fails on purpose. Stripe lists 4242424242424241 so a test can exercise the incorrect_number path 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.

  3. 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 4000000000090995
    
    number       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      consistent

    Both 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_funds card and the second is not on any list.

  4. Step 4.

    Check a number at a length its brand never issues.

    node luhn.js 42424242424242426
    
    number       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      consistent

    Seventeen 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

100% of single-digit typos and 96.7% of adjacent swaps are caught Source: measured on 2026-09-12 over the 73 Luhn-consistent sandbox numbers: 10377 single-digit variants, 0 still pass; 538 swap variants, 18 still pass, all of them a 0 next to a 9

Common mistakes

Sign: The form reports a valid card once the checksum passes.Cause: The checksum is arithmetic over the digits. It cannot tell whether the account exists, is open, is funded or is accepted in this country. The only honest message at this point is that the number is well formed, and the first authorisation is what decides the rest.
Sign: A fixture number is rejected by the sandbox as an invalid number.Cause: Two digits got swapped copying it, and if the swapped pair was a 0 and a 9 the checksum let it through. Run the sweep over the fixture file rather than trusting that a number which passes Luhn is the number the provider published.
Sign: A test uses a number someone generated with a Luhn tool.Cause: A generator produces a consistent number, which means it can also produce a real account number belonging to a stranger. Use numbers a provider publishes for its own sandbox, because those are guaranteed to be rejected by any live endpoint.
Sign: Spaces or dashes in the input make the check fail.Cause: Card numbers are printed in groups and people paste them that way. Strip everything that is not a digit before the checksum, then reject a value that still has a non-digit in it, and say which character was wrong.

What to check next

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.

basic9 minpublished updated Maks Verny