DEV Community

Cover image for Validating Card Numbers Before Checkout: Luhn, Prefixes and Expiry
Moksh Gupta
Moksh Gupta

Posted on Originally published at devtoollab.com

Validating Card Numbers Before Checkout: Luhn, Prefixes and Expiry

A checkout form gets a mistyped card number far more often than a fraudulent one, and sending that typo to your processor costs a round trip, a decline in your metrics and an annoyed customer. Three checks run entirely in the browser and catch almost all of it: the number is 12 to 19 digits once spaces and dashes are gone, it passes the Luhn checksum, and its leading digits and length match a real card brand.

None of those checks tells you the card exists. Only the issuing bank can say a card is open and funded, and it says so through your processor. Offline validation is a typo filter. I covered the same ground with more detail in the original guide on DevToolLab; this is the condensed version.

Luhn in One Worked Example

The Luhn check is a mod 10 checksum that Hans Peter Luhn of IBM patented as a "computer for verifying numbers" (US Patent 2,950,048, filed January 6, 1954, granted August 23, 1960). A card's last digit is picked so that the whole number passes it.

Take Stripe's test Mastercard, 5555 5555 5555 4444, and read it from the right. Every second digit gets doubled, and a doubled value above 9 has 9 taken off it. The rightmost 4 stays 4, the next 4 becomes 8, and each doubled 5 becomes 10, then 1. The processed digits are 4 8 4 8 5 1 5 1 5 1 5 1 5 1 5 1, and they add up to 60. A total ending in 0 means the number passes. Change the final 4 to a 3 and the total drops to 59, so the typo is caught.

That is exactly the job Luhn was built for. In a brute-force run over 2,000 random 16-digit numbers, it rejected all 288,000 single-digit substitutions and 88 of the 90 possible swaps of two neighboring digits. The only swaps it cannot see are 09 and 90. It offers no security at all, though: anyone can compute a valid check digit, which is why generated test numbers pass.

DevToolLab's Credit Card Validator page, with a card number field and one-click test numbers for Visa, Mastercard, Amex and Discover

If you only need to check a number by hand, the Credit Card Validator runs the Luhn and brand checks in your browser without sending the number anywhere.

The Check in JavaScript, Python and Java

JavaScript, using reduce over the reversed digits. Keep the card number a string the whole way, for a reason covered below:

const luhnOk = (card) => {
  const digits = card.replace(/[ -]/g, "");
  if (!/^\d{12,19}$/.test(digits)) return false;
  const sum = [...digits].reverse().reduce((acc, ch, i) => {
    let d = Number(ch);
    if (i % 2 === 1) d = d * 2 > 9 ? d * 2 - 9 : d * 2;
    return acc + d;
  }, 0);
  return sum % 10 === 0;
};

console.log(luhnOk("5555 5555 5555 4444")); // Stripe test Mastercard
console.log(luhnOk("5555 5555 5555 4443")); // one digit off
console.log(luhnOk("5555-5555-5555-444"));  // too short
Enter fullscreen mode Exit fullscreen mode
true
false
false
Enter fullscreen mode Exit fullscreen mode

Python can slice the two halves apart instead of looping, since digits[-1::-2] is every digit that stays and digits[-2::-2] is every digit that gets doubled:

def luhn_ok(card: str) -> bool:
    digits = [int(c) for c in card if c.isdigit()]
    if len(digits) != len(card.replace(" ", "").replace("-", "")) or not 12 <= len(digits) <= 19:
        return False
    odd = digits[-1::-2]
    even = [d * 2 - 9 if d * 2 > 9 else d * 2 for d in digits[-2::-2]]
    return (sum(odd) + sum(even)) % 10 == 0

print(luhn_ok("5555 5555 5555 4444"))
print(luhn_ok("5555 5555 5555 4443"))
print(luhn_ok("5555 5555 5555 444a"))
Enter fullscreen mode Exit fullscreen mode
True
False
False
Enter fullscreen mode Exit fullscreen mode

Java runs as a single file with java LuhnCheck.java on Java 11 or later (this output is from Java 24):

public class LuhnCheck {
    static boolean luhnOk(String card) {
        String digits = card.replaceAll("[ -]", "");
        if (!digits.matches("\\d{12,19}")) return false;
        int sum = 0;
        for (int i = 0; i < digits.length(); i++) {
            int d = digits.charAt(digits.length() - 1 - i) - '0';
            if (i % 2 == 1) d = d * 2 > 9 ? d * 2 - 9 : d * 2;
            sum += d;
        }
        return sum % 10 == 0;
    }

    public static void main(String[] args) {
        System.out.println(luhnOk("5555 5555 5555 4444"));
        System.out.println(luhnOk("5555 5555 5555 4443"));
    }
}
Enter fullscreen mode Exit fullscreen mode
true
false
Enter fullscreen mode Exit fullscreen mode

Rather not maintain this yourself? Braintree's card-validator package (MIT, version 10.0.4, published January 28, 2026) bundles Luhn, brand detection and expiry parsing. The DevToolLab guide shows it run against the same test numbers.

Matching the Prefix to a Brand

Each network owns a set of leading digits and allows only certain lengths. These ranges come from Braintree's open-source credit-card-type data, version 10.3.0:

Brand Starts with Lengths Security code
Visa 4 16, 18, 19 3 digits
Mastercard 51-55, 2221-2720 16 3 digits
American Express 34, 37 15 4 digits
Discover 6011, 644-649, 65 16, 19 3 digits

Mastercard's 2-series range is where hand-written patterns break. The tempting shortcut /^2[2-7]/ also swallows 2200 to 2220, and Braintree's data puts 2200 to 2204 on Mir, a separate network. It catches 2721 and above too, which are not Mastercard at all. Spelling the range out avoids both:

const brandOf = (card) => {
  const d = card.replace(/[ -]/g, "");
  if (/^4/.test(d)) return [16, 18, 19].includes(d.length) ? "Visa" : "Visa, bad length";
  if (/^(5[1-5]|222[1-9]|22[3-9]\d|2[3-6]\d\d|27[01]\d|2720)/.test(d)) return d.length === 16 ? "Mastercard" : "Mastercard, bad length";
  if (/^3[47]/.test(d)) return d.length === 15 ? "American Express" : "American Express, bad length";
  if (/^(6011|64[4-9]|65)/.test(d)) return [16, 19].includes(d.length) ? "Discover" : "Discover, bad length";
  return "unknown";
};

for (const n of ["5555555555554444", "2223003122003222", "2200000000000004", "6011111111111117", "3782822463100050"]) {
  console.log(n.padEnd(17), brandOf(n));
}
Enter fullscreen mode Exit fullscreen mode
5555555555554444  Mastercard
2223003122003222  Mastercard
2200000000000004  unknown
6011111111111117  Discover
3782822463100050  American Express, bad length
Enter fullscreen mode Exit fullscreen mode

A regex answers "which brand", never "is it valid". Run it and the Luhn check, and require both to pass.

Expiry Dates Run to the End of the Month

A card printed 09/26 keeps working through the last day of September 2026. Compare against the first day of the following month, not the printed one:

// A card printed 09/26 works through September 30, 2026.
const stillValid = (mm, yy, today) => today < new Date(2000 + Number(yy), Number(mm), 1);

const today = new Date(2026, 8, 30); // September 30, 2026
console.log(stillValid("09", "26", today));
console.log(stillValid("08", "26", today));
Enter fullscreen mode Exit fullscreen mode
true
false
Enter fullscreen mode Exit fullscreen mode

Test Numbers, Not Real Cards

Use the numbers your processor publishes. Stripe's testing docs list one for each major brand, with the instruction to "Use a valid future date, such as 12/34" and any three-digit CVC (four for American Express). They are also explicit that "The Stripe Services Agreement prohibits testing in live mode using real payment method details."

Stripe's testing docs listing test card numbers for Visa, Mastercard, Mastercard 2-series, American Express and Discover, each with its CVC and date rule

Errors You Will Actually See

Stripe answers a bad number with incorrect_number ("The card number is incorrect. Check the card’s number or use a different card.") or invalid_number ("The card number is invalid. Check the card details or use a different card."), and a stale date with expired_card. All three come back only after the request is sent, which is the round trip a client-side check saves.

The sneakier bug is a real 19-digit card failing your own check. That happens when something parsed the number into a JavaScript Number. Stripe's 19-digit UnionPay test card, 6205500000000000004, becomes 6205500000000000000, because it is larger than Number.MAX_SAFE_INTEGER (9007199254740991). The rounded digits then fail Luhn. Card numbers are strings from the input field to the API call.

Where This Validation Belongs

In the browser, for fast feedback, and nowhere near your own server. Stripe's integration security guide warns that handling raw card data directly "might be required to meet more than 300 security controls in PCI DSS", and recommends hosted fields that send the number straight to Stripe. Never log a card number, and never read a passing Luhn check as a fraud signal: it only proves the digits were typed consistently.

The same check-digit idea protects bank account numbers too, with a MOD-97 checksum instead of mod 10; the IBAN Validator runs that one.

References

Top comments (0)