Luhn Algorithm Credit Card Validation

How do you tell, in the browser and before any network request, that a user has mistyped their card number? The Luhn algorithm — a mod-10 checksum built into every payment card number — answers exactly that question. This recipe implements it in TypeScript without string allocations, explains the arithmetic with a worked example, wires the verdict into setCustomValidity() so the Constraint Validation API reports it like any other rule, and spells out the classes of error it catches and the ones it silently lets through.

When to Use the Luhn Check

Use Luhn whenever you render your own input for a number that carries a Luhn check digit. That includes:

  • Payment card numbers (all major networks).
  • IMEI numbers on device registration forms.
  • Canadian Social Insurance Numbers and several national identifiers.
  • Some loyalty and gift card numbers — check the issuer’s specification first.

Do not use it to decide whether a card is genuine; it has no idea whether an account exists. Do not use it on IBANs either — those use a different, stronger mod-97 checksum covered in IBAN validation with mod 97. And run it after the brand-specific length check from the payment card validation topic: reporting “a digit is wrong” on a 12-digit Amex number is less helpful than “American Express card numbers are 15 digits long”.

The Luhn checksum in four steps Starting from the rightmost digit, every second digit is doubled, nine is subtracted from any result above nine, all digits are summed, and the number is valid when the sum is divisible by ten. Walk right to left start at the check digit Double every second positions 2, 4, 6 … from the right Fold results above 9 16 becomes 7, 18 becomes 9 Sum mod 10 valid when the remainder is 0
The check digit at the far right is chosen by the issuer so that this sum always lands on a multiple of ten.

Minimal Working Luhn Implementation

The implementation reads character codes directly instead of splitting into an array, so it allocates nothing and runs in well under a microsecond for a 19-digit number. It expects digits only; normalise first.

/**
 * Luhn (mod 10) checksum. Expects a string of ASCII digits only.
 * Returns false for empty input so an empty field never "passes".
 */
export function luhnValid(digits: string): boolean {
  if (digits.length === 0) return false;
  let sum = 0;
  let double = false;
  for (let i = digits.length - 1; i >= 0; i--) {
    const code = digits.charCodeAt(i);
    if (code < 48 || code > 57) return false; // defensive: non-digit
    let n = code - 48;
    if (double) {
      n *= 2;
      if (n > 9) n -= 9; // same as summing the two digits of n
    }
    sum += n;
    double = !double;
  }
  return sum % 10 === 0;
}

/** Compute the check digit to append to a partial number (for test fixtures). */
export function luhnCheckDigit(partial: string): number {
  for (let d = 0; d <= 9; d++) if (luhnValid(partial + d)) return d;
  throw new Error("unreachable");
}

// Wiring into the canonical novalidate + reportValidity flow
const form = document.querySelector<HTMLFormElement>("#checkout")!;
const field = form.querySelector<HTMLInputElement>("#cc-number")!;
const MESSAGE = "Check your card number — it looks like a digit is wrong.";

function validateCardNumber(): void {
  const digits = field.value.replace(/[\s-]/g, "");
  const lengthOk = digits.length >= 12 && digits.length <= 19 && /^\d+$/.test(digits);
  // Only judge the checksum once the number could be complete.
  field.setCustomValidity(lengthOk && !luhnValid(digits) ? MESSAGE : "");
}

field.addEventListener("input", validateCardNumber);
form.addEventListener("submit", (event) => {
  validateCardNumber();
  if (!form.checkValidity()) {
    event.preventDefault();
    form.reportValidity();
  }
});

A worked example

Take the common test number 4539 1488 0343 6467. Reading from the right, the digits in odd positions (1st, 3rd, 5th…) are kept as they are, and those in even positions are doubled:

Position from right Digit Doubled? Value added
1 7 no 7
2 6 yes → 12 → 1+2 3
3 4 no 4
4 6 yes → 12 → 3 3
5 3 no 3
6 4 yes → 8 8
7 3 no 3
8 0 yes → 0 0
9 8 no 8
10 8 yes → 16 → 7 7
11 4 no 4
12 1 yes → 2 2
13 9 no 9
14 3 yes → 6 6
15 5 no 5
16 4 yes → 8 8

The values sum to 80, which is divisible by 10, so the number passes. Change any single digit and the sum moves by a non-zero amount smaller than ten, so it can no longer be a multiple of ten — that is why every single-digit error is caught.

Luhn Validator Option Reference

Option Type Default Purpose
Input normalisation regex /[\s-]/g Remove formatting before checking
Minimum length to judge number 12 Avoid flagging a number that is still being typed
Maximum length number 19 Longest issued card number
Error message string “Check your card number…” Does not claim which digit is wrong
Empty input result boolean false Empty never passes; required reports it instead
Trigger event input + submit Live correction plus the submit-time safety net
Share of typing errors detected by Luhn A bar chart showing how many errors of each common typing error class the Luhn checksum detects. Single-digit substitution 100 % detected Adjacent transposition 98 % detected Twin error (11 → 22) 93 % detected Jump transposition (abc → cba) 0 % detected
Luhn catches every single-digit substitution and nearly all adjacent swaps; its blind spots are the 09↔90 swap and some twin errors.

Verification Steps

import { describe, it, expect } from "vitest";
import { luhnValid, luhnCheckDigit } from "./luhn";

describe("luhnValid", () => {
  it.each([
    ["4539148803436467", true],
    ["4539148803436468", false],   // last digit changed
    ["4539148803436476", false],   // adjacent swap 67 → 76
    ["79927398713", true],
    ["", false],
  ])("%s → %s", (input, expected) => expect(luhnValid(input)).toBe(expected));

  it("generates fixtures that validate", () => {
    const partial = "411111111111111";
    expect(luhnValid(partial + luhnCheckDigit(partial))).toBe(true);
  });
});

Edge Cases and Failure Modes

The 09 ↔ 90 transposition. Swapping adjacent 0 and 9 leaves the sum unchanged: doubled 9 folds to 9, doubled 0 stays 0, so both orders contribute 9. Luhn cannot catch this one, and no amount of client code will — the provider’s decline is the backstop.

Checking too early. Running Luhn from the first keystroke flashes “a digit is wrong” at almost every intermediate length, because roughly 90% of prefixes fail the checksum by chance. Gate the check on a plausible complete length, as the implementation does, or on the brand’s exact length once it is known.

Non-ASCII digits. Users with some keyboard layouts can type full-width digits (4242) or Arabic-Indic digits. charCodeAt returns values outside 48–57 for these, and the defensive branch rejects them. Better: normalise them first with value.normalize("NFKC"), which turns full-width digits into ASCII, and give a specific message for anything that remains.

const digits = field.value.normalize("NFKC").replace(/[\s-]/g, "");

Why Luhn Catches What It Catches

The design is elegant once you see it. Doubling alternate digits means each position contributes differently to the sum, so moving a digit to a neighbouring position usually changes the total; folding values above nine keeps every contribution a single digit, so the sum is bounded and cheap to compute. A single substituted digit changes its contribution by between 1 and 9 (whether or not it is in a doubled position, because doubling-and-folding maps 0–9 onto 0–9 as a permutation), so the sum can never shift by exactly ten — detection is guaranteed. For adjacent swaps, the change in the sum is the difference between the two digits’ doubled and undoubled values, which is non-zero for every pair except 0 and 9. That is the whole algorithm’s strength and its limit: it was designed in the 1950s to catch human keying errors cheaply, and it still does precisely that.

This also explains why Luhn is not a security feature. Anyone can compute a valid check digit for any prefix — the luhnCheckDigit helper above does it in a loop — so “passes Luhn” carries no information about authenticity. Treat the check as a typo detector that saves the user a declined payment, in the same spirit as the synchronous validation patterns elsewhere on the site, and let the provider’s authorisation be the real verdict.

Where Luhn sits among card checks A layered view of card number validation from cheap format checks at the top to the issuer authorisation at the bottom, with Luhn in the middle. Format digits and spaces only, normalised Brand and length IIN prefix decides 14, 15, 16 or 19 digits Luhn checksum catches single-digit and most swap typos Provider tokenisation network-level number and expiry checks Issuer authorisation account exists, open and funded
Each layer catches a different class of problem; Luhn is the last check the browser can do before only the issuer knows the answer.

Performance: Luhn on Every Keystroke

Because the implementation walks at most 19 characters with integer arithmetic, running it on every input event is effectively free — measured in tens of nanoseconds. There is no reason to debounce it, and debouncing would only delay the moment an error clears after the user corrects a digit. If profiling shows your card field is slow, the cost is almost always elsewhere: a formatter that rebuilds the value with regular expressions on every keystroke, a brand-detection table compiled inside the handler, or a framework re-render triggered by setting state three times. The guidance in memoizing expensive synchronous validators applies to those, not to the checksum itself.

Frequently Asked Questions

Does the Luhn check prove a credit card number is valid?

It proves the number is internally consistent, which catches almost all typing mistakes. It says nothing about whether the account exists, is open, or has funds; only an authorisation through your payment provider can tell you that.

Which errors does Luhn fail to detect?

It misses swaps of adjacent 0 and 9 (09 ↔ 90), some twin errors such as 22 ↔ 55, and transpositions of non-adjacent digits. These are rare in practice, and the provider decline covers them.

Should I run the Luhn check on every keystroke?

Compute it on every input — it is extremely cheap — but only show a checksum error once the number reaches a plausible complete length. Otherwise most partial numbers flash a false error.

Can I use the same code for IMEI numbers?

Yes. IMEIs are 15 digits with a Luhn check digit, so the same function works; just change the length gate and the error message.

← Back to Payment Card Validation