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 i
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.
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
true
false
false
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"))
True
False
False
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"));
}
}
true
false
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));
}
5555555555554444 Mastercard
2223003122003222 Mastercard
2200000000000004 unknown
6011111111111117 Discover
3782822463100050 American Express, bad length
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));
true
false
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."
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
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.
