
Luhn Algorithm in TypeScript: 1.5M Tests, One Blind Spot
Luhn catches every single-digit typo, but one swap always slips through. I tested it in TypeScript with 1.5M cases and compared it with Damm and Verhoeff.
Andre Avindra
Introduction
Change a single digit in a card number on a checkout form, and many forms reject it immediately. There is no loading spinner and no call to the bank. The rejection can happen right in your browser within milliseconds, using arithmetic you could do on a calculator.
That is the Luhn algorithm. I learned it as a software engineer who has never used it in production, so I chose the most honest way to learn: testing its claims myself, with more than 1.5 million test cases. This article covers the concept, a TypeScript implementation, and several experiments, including one pair of digits that always slips through.
At the end, there is an interactive simulation you can try yourself to see the calculation step by step.
What Is the Luhn Algorithm?
The Luhn algorithm, also known as modulus 10 or mod 10, is a simple checksum formula for validating identification numbers. It was created by Hans Peter Luhn, a computer scientist at IBM, and patented in 1960. It is now in the public domain.
One point is important to understand from the start: Luhn is not about security. It is designed to detect accidental mistakes, such as one mistyped digit or two swapped digits. It cannot prevent forgery, because anyone can compute the checksum.
Where Is It Used?
- Most credit and debit card numbers (Visa, Mastercard, American Express, and others)
- IMEI numbers on mobile phones
- Some identification numbers, such as the Canadian SIN and the Greek AMKA
All of these formats place a single check digit at the rightmost position. That digit is computed from the digits before it.
Not Everything Uses Luhn
Not every number that has a check digit uses Luhn:
Number | Check digit | Luhn? |
|---|---|---|
Almost all credit and debit cards | Luhn | ✅ Yes |
Mobile phone IMEI | Luhn | ✅ Yes |
Canadian SIN | Luhn | ✅ Yes |
Greek AMKA | Luhn | ✅ Yes |
EAN/UPC barcode | Mod 10 with weights 3 and 1 | ❌ No |
US bank routing number | Mod 10 with weights 3-7-1 | ❌ No |
US Social Security Number | No check digit | ❌ No |
Barcodes and routing numbers use a similar idea, a weighted sum modulo 10, but without Luhn's "double, then subtract 9" step. The US Social Security Number has no check digit at all, so only its format can be checked.
Where Luhn Fits in the Checkout Flow
Luhn is only the very first step. Roughly, this is what happens when a user pays:
- The form checks the format and length of the number.
- Luhn checks the number in the browser, instantly and without a network connection. Typos are rejected right here.
- Numbers that pass are sent to the payment gateway for further checks.
- The card issuer decides whether the card is real, active, and has enough funds. This is where a random number that happens to pass Luhn gets rejected.
How It Works: Calculate It Yourself with a Calculator
To validate a number:
- Start from the rightmost digit (the check digit) and move left.
- Do not double the rightmost digit. Double the digit to its left, leave the next one alone, and keep alternating.
- If a doubled value is greater than 9, subtract 9 (this is equivalent to adding its two digits, for example 16 becomes 1 + 6 = 7).
- Add up all the resulting digits.
- If the total is divisible by 10 (total mod 10 = 0), the number is valid.
Example: Validating 79927398713
This is the classic example often used in the literature.
Digit (from the right) | Doubled? | Processed value |
|---|---|---|
3 | ❌ No | 3 |
1 | ✅ Yes | 2 |
7 | ❌ No | 7 |
8 | ✅ Yes | 16 → 7 |
9 | ❌ No | 9 |
3 | ✅ Yes | 6 |
7 | ❌ No | 7 |
2 | ✅ Yes | 4 |
9 | ❌ No | 9 |
9 | ✅ Yes | 18 → 9 |
7 | ❌ No | 7 |
Total: 3 + 2 + 7 + 7 + 9 + 6 + 7 + 4 + 9 + 9 + 7 = 70. Since 70 is divisible by 10, this number is valid.
Try changing just one digit, for example the last digit to 4. The total becomes 71, which is not divisible by 10, so the number is rejected.
Calculating the Check Digit
What if you have a number without a check digit and want to create one? The principle is the same, except that the rightmost digit of the payload is now doubled, because it will shift one position when the check digit is appended.
For the payload 7992739871:
- The sum after the alternating process = 67
- Check digit = (10 − (67 mod 10)) mod 10 = 3
The result is 79927398713, exactly the example above.
Why Double, Then Subtract 9? The Trick Behind the Algorithm
The key is the "double, then subtract 9" step. The mapping for digits in doubled positions is:
Digit | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
Result | 0 | 2 | 4 | 6 | 8 | 1 | 3 | 5 | 7 | 9 |
This mapping is a permutation: every digit from 0 to 9 maps to a different digit. As a result:
- A single mistyped digit is always detected. Replacing one digit with another always changes the total, and the change can never be a multiple of 10.
- Most swaps of two adjacent digits are detected. For example,
34becoming43produces a different total.
The Gaps Luhn Does Not Close
After studying it, here are a few things I think are worth remembering:
- It does not detect swapping
09and90. For this pair, the final sum does not change (the reason is explained in the experiments section), so the error slips through. - It does not detect every two-digit error. Some combinations of two errors can cancel each other out.
- Passing Luhn does not mean the number is real. A number that is mathematically valid is not necessarily registered or active. Real validation must still be done with the service provider.
- It is not a security mechanism. Never rely on it as your only protection.
For that reason, Luhn is best used as a first filter on the client or server to give users quick feedback.
But how wide are these gaps, really? Instead of simply trusting the phrase "almost all", let's measure it ourselves.
Implementation with TypeScript
All code in this article is TypeScript with no dependencies. The core functions are small enough to read in one sitting.
Core Functions
// luhn.ts
export function luhnChecksum(digits: string): number {
let total = 0;
let shouldDouble = false; // the rightmost digit is not doubled
for (let i = digits.length - 1; i >= 0; i--) {
let d = digits.charCodeAt(i) - 48; // "7" -> 7
if (shouldDouble) {
d *= 2;
if (d > 9) d -= 9;
}
total += d;
shouldDouble = !shouldDouble;
}
return total % 10;
}
export function isValidLuhn(input: string): boolean {
const digits = input.replace(/[\s-]/g, "");
return /^\d+$/.test(digits) && luhnChecksum(digits) === 0;
}
export function generateCheckDigit(payload: string): number {
// Temporarily append "0" in the check digit position
return (10 - luhnChecksum(payload + "0")) % 10;
}
// Usage examples
isValidLuhn("79927398713"); // true
isValidLuhn("79927398714"); // false
generateCheckDigit("7992739871"); // 3Design Decisions
- A single loop from right to left with a
shouldDoubleflag, with no string reversal and no new arrays. charCodeAt(i) - 48converts a digit character to a number, because the character code of "0" is 48.- Spaces and hyphens are removed first, because users often type
4242 4242 4242 4242. - The function is pure and has no dependencies, so it is easy to test and works in both Node and the browser.
generateCheckDigit is useful when you create your own numbers, such as voucher codes or membership numbers that should tolerate typos.
Experiment: How Much Is "Almost All"?
Many articles say that Luhn detects all single-digit errors and almost all swaps of adjacent digits. I wrote a script to measure it. The script uses luhn.ts above and runs with bun experiment.ts. The numbers can shift slightly on each run because the test numbers are random.
# | Scenario | What was tested | Rejected by Luhn |
|---|---|---|---|
1 | One wrong digit | Every position × every replacement digit, 1.44 million cases | 100% |
2 | Two adjacent digits swapped | Only pairs of different digits, about 135 thousand cases | 97.8% |
3 | Two digits at random positions wrong | 10,000 cases | 89.08% |
4 | Random 16-digit string | 10,000 cases | 89.81% rejected, 10.19% pass |
// experiment.ts
import { generateCheckDigit, isValidLuhn } from "./luhn";
const randInt = (n: number) => Math.floor(Math.random() * n);
const randomDigits = (len: number) =>
Array.from({ length: len }, () => randInt(10)).join("");
const replaceAt = (s: string, i: number, c: string) =>
s.slice(0, i) + c + s.slice(i + 1);
// Create a 16-digit number that is valid according to Luhn
function makeValid(len = 16): string {
const payload = randomDigits(len - 1);
return payload + generateCheckDigit(payload);
}
// [rejected, total] for each scenario
const stats = {
singleDigitError: [0, 0],
adjacentSwap: [0, 0],
twoRandomErrors: [0, 0],
randomString: [0, 0],
};
const N = 10_000;
for (let n = 0; n < N; n++) {
const valid = makeValid();
// 1. Replace one digit with another (every position x every digit)
for (let i = 0; i < valid.length; i++) {
for (let c = 0; c <= 9; c++) {
if (String(c) === valid[i]) continue;
stats.singleDigitError[1]++;
if (!isValidLuhn(replaceAt(valid, i, String(c)))) stats.singleDigitError[0]++;
}
}
// 2. Swap two adjacent digits that differ
for (let i = 0; i < valid.length - 1; i++) {
if (valid[i] === valid[i + 1]) continue;
const swapped = replaceAt(replaceAt(valid, i, valid[i + 1]), i + 1, valid[i]);
stats.adjacentSwap[1]++;
if (!isValidLuhn(swapped)) stats.adjacentSwap[0]++;
}
// 3. Replace two digits at random positions
const i = randInt(16);
let j = randInt(16);
while (j === i) j = randInt(16);
let broken = valid;
for (const k of [i, j]) {
let c: string;
do {
c = String(randInt(10));
} while (c === valid[k]);
broken = replaceAt(broken, k, c);
}
stats.twoRandomErrors[1]++;
if (!isValidLuhn(broken)) stats.twoRandomErrors[0]++;
// 4. Random string: how many pass by chance?
stats.randomString[1]++;
if (!isValidLuhn(randomDigits(16))) stats.randomString[0]++;
}
for (const [name, [rejected, total]] of Object.entries(stats)) {
const percent = ((rejected / total) * 100).toFixed(2);
console.log(`${name}: ${percent}% rejected (${rejected}/${total})`);
}What I Found
- Experiment 1 is consistent with the claim. Out of 1.44 million cases, not a single one-digit error got through.
- Experiment 2 explains what "almost all" means. Of the 90 possible pairs of different digits, only two go undetected:
09becoming90, and the reverse. That is 2 out of 90, about 2.2%, which matches the 97.8% in the table. The reason: the digit 0 stays 0 and the digit 9 stays 9 after being doubled and reduced by 9, so swapping their positions does not change the sum. - A concrete example.
42093and42903both pass Luhn, even though two of their digits are swapped. Check it yourself withisValidLuhn. - Experiment 3 shows the limit of detection. Roughly 1 in 9 double errors slips through, because two errors can cancel each other out.
- Experiment 4 is a reminder of what "valid" means. About 1 in 10 random strings pass Luhn. Passing only means the number looks plausible, not that the card exists.
Verhoeff and Damm: Two Rivals of Luhn
Luhn is not the only decimal check digit algorithm. Two of the best-known rivals were designed specifically to close its gaps:
- Verhoeff (1969). Created by the Dutch mathematician Jacobus Verhoeff. It treats digits as elements of the dihedral group D5, the symmetries of a pentagon, rather than as plain numbers. As a result, it detects all single-digit errors, all adjacent swaps, and most other kinds of errors. Its drawback is complexity: it needs three tables. Verhoeff is used, among other places, in India's Aadhaar numbers.
- Damm (2004). Presented by H. Michael Damm in his doctoral dissertation on quasigroups. At its core is a single 10×10 table, and it produces only 10 possible values, so the check digit is always a single digit. Damm also detects all single-digit errors and all adjacent swaps, with a simpler implementation than Verhoeff.
I implemented both with the same interface as Luhn, then ran the same experiment with three additional error types: twin digits (11 becoming 22), swapping digits with one digit between them (123 becoming 321), and two random wrong digits.
// damm.ts
const TABLE = [
[0, 3, 1, 7, 5, 9, 8, 6, 4, 2],
[7, 0, 9, 2, 1, 5, 4, 8, 6, 3],
[4, 2, 0, 6, 8, 7, 1, 3, 5, 9],
[1, 7, 5, 0, 9, 8, 3, 4, 2, 6],
[6, 1, 2, 3, 0, 4, 5, 9, 7, 8],
[3, 6, 7, 4, 2, 0, 9, 5, 8, 1],
[5, 8, 6, 9, 7, 2, 0, 1, 3, 4],
[8, 9, 4, 5, 3, 6, 2, 0, 1, 7],
[9, 4, 3, 8, 6, 1, 7, 2, 0, 5],
[2, 5, 8, 1, 4, 3, 6, 7, 9, 0],
] as const;
export function dammInterim(digits: string): number {
let interim = 0;
for (const ch of digits) interim = TABLE[interim][ch.charCodeAt(0) - 48];
return interim;
}
export const isValidDamm = (digits: string) =>
/^\d+$/.test(digits) && dammInterim(digits) === 0;
export function dammCheckDigit(payload: string): number {
for (let c = 0; c <= 9; c++) {
if (dammInterim(payload + c) === 0) return c;
}
throw new Error("unreachable");
}Verhoeff is longer because it uses three tables:
// verhoeff.ts
const D = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
[2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
[4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
[5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
[7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
[8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
] as const;
const P = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
[5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
[9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
[4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
[7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
] as const;
const INV = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9] as const;
function compute(digits: string, offset: number): number {
let c = 0;
for (let i = 0; i < digits.length; i++) {
const digit = digits.charCodeAt(digits.length - 1 - i) - 48;
c = D[c][P[(i + offset) % 8][digit]];
}
return c;
}
export const isValidVerhoeff = (digits: string) =>
/^\d+$/.test(digits) && compute(digits, 0) === 0;
export const verhoeffCheckDigit = (payload: string) =>
INV[compute(payload, 1)];
// verhoeffCheckDigit("236") === 3, isValidVerhoeff("2363") === trueComparison Results for Luhn, Damm, and Verhoeff
Each cell comes from thousands of random, valid 16-digit numbers, each corrupted with one type of error. The script is similar to experiment.ts, with the validation function swapped out and two additional scenarios (twin digits and swapping digits with one digit between them). The full script, a single file containing all three algorithms and the comparison, is in a Gist: luhn-damm-verhoeff-compare.ts. The numbers can shift slightly each time the script runs, and because they come from random numbers, they may differ for real numbers that have fixed prefixes.
Scenario (16-digit numbers) | Luhn | Damm | Verhoeff |
|---|---|---|---|
One wrong digit | 100% | 100% | 100% |
Two adjacent digits swapped ( | 97.8% | 100% | 100% |
Twin digits changed ( | 93.3% | 91.3% | 95.5% |
Swap with one digit in between ( | 0% | 89.8% | 94.3% |
Two random digits wrong | 88.4% | 89.3% | 88.7% |
From my experiments, the overall order is Verhoeff ≥ Damm ≥ Luhn, but not for every error type: for twin digits, Luhn (93.3%) is slightly better than Damm (91.3%). The biggest weakness of Luhn that I found is that swapping digits with one digit in between (123 becoming 321) is never detected. Both digits sit in positions that are treated the same way, either both doubled or both not, so the sum does not change. Damm and Verhoeff catch all adjacent swaps, and Verhoeff is the strongest on the other error types.
For two random errors, all three are about the same: roughly 1 in 9 slips through. That makes sense, because a single check digit has only 10 possible values, so no algorithm can catch every double error.
So Why Is Luhn Still the One in Use?
Luhn most likely survives because of timing and practicality, not because it has the strongest detection. This is my conclusion, for the following reasons:
- It came first. Luhn was designed in 1954 and patented in 1960, and it is now in the public domain. Verhoeff was published in 1969 and Damm in 2004. When Damm arrived, Luhn was already firmly established in card systems.
- It is simple. Luhn needs only one loop and a little arithmetic, with no tables. Verhoeff needs three tables, and Damm needs one 10×10 table.
- It is already the standard. Card numbers and IMEIs already use Luhn for their check digit. Replacing it would mean changing numbers already in circulation on millions of cards and devices, along with every system that validates them.
- It is good enough for the most common mistakes. According to the discussion of the Verhoeff scheme on the Atractor website (see References), about 90% of common input errors are either a single wrong digit or an adjacent swap. Luhn catches the first completely and the second almost entirely.
- It is not a security tool. Its purpose is only to filter out typos. A small gap in Luhn has little impact, because the card issuer still verifies the card number.
My takeaway: for a new identifier system, Damm (simpler) or Verhoeff (slightly stronger) is worth considering. For card numbers, IMEIs, or any other format that already specifies Luhn, you still use Luhn.
Pitfalls When Implementing
- Doubling from the left instead of the right. The result is wrong for numbers with an odd length. Always start from the rightmost digit.
- Doubling the check digit too. The check digit is the first position from the right and is not doubled.
- Storing the number as a
number. A 16-digit number can exceedNumber.MAX_SAFE_INTEGERand lose precision, and leading zeros disappear. Store it as astring. - Forgetting to strip spaces and hyphens. Users type
4242 4242 4242 4242, not4242424242424242.
Try It Yourself: Interactive Simulation
The fastest way to understand Luhn is to try it. I built a small simulation page: type a number, then see which digits are doubled, what the total is, and why the number is valid or not. There is also a mode for generating a check digit, and the examples 42093 and 42903 let you see the 09/90 gap for yourself.
Practical Tips
- Write unit tests with test numbers, not real card numbers. Many payment providers offer test card numbers that pass Luhn, for example the commonly used
4242 4242 4242 4242. - Do not log full card numbers, and never store the CVV. PCI DSS requires the card number (PAN) to be rendered unreadable wherever it is stored (Requirement 3.5.1 in v4.x), and it forbids storing sensitive authentication data such as the CVV after authorization, even if encrypted (Requirement 3.3.1). This is my summary; the details are in the official PCI SSC documents (see References).
Conclusion
What impressed me most about the Luhn algorithm is its simplicity. With a few lines of code and basic arithmetic, it provides an effective safety net against human error, and it has survived for decades in systems we use every day.
For me, this is a reminder that a good solution does not have to be complicated. Sometimes it just needs to be on target: identify the problem (typos), then design a cheap, fast mechanism to handle it.
If you have experience using Luhn or other checksum algorithms such as Verhoeff or Damm, feel free to share it in the comments.
References
- Luhn algorithm (Wikipedia)
- Verhoeff algorithm (Wikipedia)
- Damm algorithm (Wikipedia)
- An optimal system: the Verhoeff Scheme (Atractor)
- Check digit (Wikipedia): UPC and EAN weights and the US bank routing number
- International Article Number (Wikipedia): EAN check digit calculation
- What is the Luhn algorithm and how does it work? (Stripe)
- Luhn algorithm (Paytia): Luhn as an early check before the payment gateway
- Structure of Social Security Numbers (EFF)
- US Social Security Number validator (Axon Toolbox): SSNs have no check digit
- PCI Security Standards Council: Document Library (PCI DSS v4.0.1, official source)
- PCI DSS Requirement 3: Protect Stored Account Data (Basis Theory, secondary explanation)
- Introduction
- What Is the Luhn Algorithm?
- Where Is It Used?
- Not Everything Uses Luhn
- Where Luhn Fits in the Checkout Flow
- How It Works: Calculate It Yourself with a Calculator
- Example: Validating 79927398713
- Calculating the Check Digit
- Why Double, Then Subtract 9? The Trick Behind the Algorithm
- The Gaps Luhn Does Not Close
- Implementation with TypeScript
- Core Functions
- Design Decisions
- Experiment: How Much Is "Almost All"?
- What I Found
- Verhoeff and Damm: Two Rivals of Luhn
- Comparison Results for Luhn, Damm, and Verhoeff
- So Why Is Luhn Still the One in Use?
- Pitfalls When Implementing
- Try It Yourself: Interactive Simulation
- Practical Tips
- Conclusion
- References
Stay in the loop
New Articles, Delivered
Get my latest articles on software development and the things I’m learning, straight to your inbox.