Card number fields fail users in small, costly ways: a 15-digit American Express card grouped in fours looks wrong and gets retyped, a single mistyped digit is only discovered when the payment is declined, and a mask that blocks spaces rejects the number a user pasted from their banking app.
A well-built card field groups digits the way the card is printed, detects the brand from the first digits, and catches almost every single-digit typo instantly with the Luhn checksum — all before anything is sent. This page, part of input masking and formatting, implements those checks, and is clear about the most important decision: in most production payment forms, the card field itself should be a payment provider’s hosted field, and these techniques apply to it via the provider’s API or to non-payment card-like numbers.
Context and prerequisites
The relevant facts:
- Brand by prefix (IIN/BIN). Visa starts with
4; Mastercard with51–55or2221–2720; American Express with34or37; Discover with6011,644–649or65; and so on. The prefix determines length and grouping. - Lengths. Visa 13, 16 or 19; Mastercard 16; Amex 15; others vary between 12 and 19.
- Grouping. Most 16-digit cards print
4-4-4-4; Amex prints4-6-5; 19-digit cards often4-4-4-4-3. - Luhn checksum. A mod-10 check digit that detects every single-digit error and most adjacent transpositions. It says nothing about whether the card exists.
- PCI DSS scope. Handling raw card numbers in your own page brings your front end into compliance scope. Payment providers’ hosted fields (iframes) keep the number out of your code entirely.
The core pattern: brand detection, grouping and Luhn
interface Brand { id: string; name: string; test: (d: string) => boolean; lengths: number[]; groups: number[]; cvc: number }
const inRange = (d: string, lo: number, hi: number, len: number) => {
const p = Number(d.slice(0, len));
return d.length >= len && p >= lo && p <= hi;
};
// Ordered: more specific prefixes first.
export const BRANDS: Brand[] = [
{ id: "amex", name: "American Express", test: (d) => /^3[47]/.test(d), lengths: [15], groups: [4, 6, 5], cvc: 4 },
{ id: "mastercard", name: "Mastercard", test: (d) => /^5[1-5]/.test(d) || inRange(d, 2221, 2720, 4), lengths: [16], groups: [4, 4, 4, 4], cvc: 3 },
{ id: "discover", name: "Discover", test: (d) => /^(6011|64[4-9]|65)/.test(d), lengths: [16, 17, 18, 19], groups: [4, 4, 4, 4, 3], cvc: 3 },
{ id: "visa", name: "Visa", test: (d) => /^4/.test(d), lengths: [13, 16, 19], groups: [4, 4, 4, 4, 3], cvc: 3 },
];
export const detectBrand = (digits: string) => BRANDS.find((b) => b.test(digits));
export function groupDigits(digits: string): string {
const groups = detectBrand(digits)?.groups ?? [4, 4, 4, 4, 3];
const out: string[] = [];
let i = 0;
for (const g of groups) { if (i >= digits.length) break; out.push(digits.slice(i, i + g)); i += g; }
if (i < digits.length) out.push(digits.slice(i));
return out.join(" ");
}
/** Luhn mod-10: double every second digit from the right, subtract 9 if > 9, sum, check % 10. */
export function luhnValid(digits: string): boolean {
let sum = 0;
let double = false;
for (let i = digits.length - 1; i >= 0; i--) {
let n = digits.charCodeAt(i) - 48;
if (double) { n *= 2; if (n > 9) n -= 9; }
sum += n;
double = !double;
}
return digits.length > 0 && sum % 10 === 0;
}
export function checkCardNumber(raw: string): { ok: true; digits: string; brand?: Brand } | { ok: false; message: string } {
const digits = raw.replace(/[\s-]/g, "");
if (!digits) return { ok: false, message: "Enter your card number." };
if (/\D/.test(digits)) return { ok: false, message: "Card numbers contain only digits." };
const brand = detectBrand(digits);
const allowed = brand?.lengths ?? [12, 13, 14, 15, 16, 17, 18, 19];
if (!allowed.includes(digits.length)) {
return { ok: false, message: brand ? `${brand.name} card numbers have ${allowed.join(" or ")} digits.` : "Check the number of digits in your card number." };
}
if (!luhnValid(digits)) return { ok: false, message: "Check your card number — one of the digits may be mistyped." };
return { ok: true, digits, brand };
}
Step-by-step walkthrough
- Prefer the payment provider’s hosted fields for real payments. They handle formatting, brand detection and validation, and keep raw card numbers out of your page. Use their events (
change,error) to integrate with your form’s error summary and focus management. - Use the right input attributes for any card-like field.
type="text",inputmode="numeric",autocomplete="cc-number"(so browsers offer saved cards), and nomaxlengthon the display value. - Detect the brand from the first digits. Update grouping and the expected security-code length as soon as the prefix is known, and show the brand name as text (not only a logo).
- Group digits by brand, preserving the caret. Live grouping helps users check long numbers; apply the caret mapping from preserving caret position in masked inputs.
- Validate length and Luhn on blur. A Luhn failure means a typo; say so specifically. Do not show “invalid card” before the user has finished typing.
- Treat the provider’s decline as the real answer. A Luhn-valid number can still be declined; map provider errors (expired, insufficient funds, incorrect CVC) to form-level or field-level messages.
Why Luhn catches typos so well
The Luhn algorithm was designed to catch the mistakes people make when copying numbers by hand. Changing any single digit always changes the checksum, so every single-digit typo is detected. Swapping two adjacent digits is detected in all cases except swapping 0 and 9. Those two error types — one wrong digit, two digits swapped — account for the large majority of transcription errors, which is why a Luhn check on blur prevents most “your card was declined” round trips caused by typing rather than by the card itself. It is not a security measure and not a check that the card exists; it is a cheap, instant typo detector.
Failure modes and edge cases
1. Grouping Amex as 4-4-4-4
Amex cards are printed 3782 822463 10005. Grouping them in fours makes the typed number look different from the card and invites re-checking. Switch grouping as soon as 34 or 37 is detected.
2. Rejecting unknown brands
New BIN ranges and regional schemes appear regularly. Treat an unrecognised prefix as “unknown brand” with generic length rules, not as invalid; the provider will decide.
3. maxlength truncating pastes
maxlength="19" on a field with spaces truncates a pasted 19-digit number with separators. Validate digit count, not display length.
4. Security code length
Show the expected length from the detected brand (4 for Amex, 3 otherwise) in the hint and validate accordingly; describe where to find it in text, not only with an image.
5. Expiry dates
Accept MM/YY, MM / YY and MMYY; validate that the month is 1–12 and that the card is not expired, comparing against the end of the expiry month. Use autocomplete="cc-exp" (or cc-exp-month and cc-exp-year for separate fields).
Verification checklist
Frequently Asked Questions
Is it safe to validate card numbers in JavaScript?
Validation itself is harmless, but any code that reads raw card numbers is in the path of that data. For real payments, use hosted fields so your scripts — and any third-party scripts on the page — never see the number. The techniques here then apply to other numbers that use Luhn, such as some identity and account numbers.
Which numbers can I use for testing?
Use your payment provider’s published test numbers, such as 4111 1111 1111 1111, which are Luhn-valid but not real cards. Never use real card numbers in tests or fixtures.
Should I block the form until the card number is valid?
No. Validate on blur and on submit, show specific messages, and move focus to the problem, as with any field. Disabling the pay button until everything validates hides the reason from users, as discussed in disabling submit buttons without hiding the reason.