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 with 51–55 or 2221–2720; American Express with 34 or 37; Discover with 6011, 644–649 or 65; 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 prints 4-6-5; 19-digit cards often 4-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.
Common card brands, lengths and grouping Visa cards start with 4, have 13, 16 or 19 digits, are grouped 4-4-4-4 and use a 3-digit security code. Mastercard starts with 51 to 55 or 2221 to 2720, has 16 digits, grouped 4-4-4-4, with a 3-digit code. American Express starts with 34 or 37, has 15 digits, grouped 4-6-5, with a 4-digit code on the front. Discover starts with 6011, 644 to 649 or 65, has 16 to 19 digits, grouped 4-4-4-4, with a 3-digit code. Brand Prefix Length Grouping CVC Visa 4 13, 16, 19 4-4-4-4 3 Mastercard 51–55, 2221–2720 16 4-4-4-4 3 American Express 34, 37 15 4-6-5 4 Discover 6011, 644–649, 65 16–19 4-4-4-4 3 Prefix ranges change over time; keep the table in data you can update, and let the payment provider be the final authority.

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

  1. 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.
  2. Use the right input attributes for any card-like field. type="text", inputmode="numeric", autocomplete="cc-number" (so browsers offer saved cards), and no maxlength on the display value.
  3. 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).
  4. 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.
  5. 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.
  6. 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.

Luhn on a 16-digit number Take the digits of 4111 1111 1111 1111. Starting from the rightmost digit, leave it, double the next, and alternate. The doubled positions are the eight digits in odd positions from the left: 4 becomes 8 and each 1 becomes 2. The undoubled digits are all 1. The sum is 8 plus seven 2s plus eight 1s, which is 8 plus 14 plus 8, equal to 30. Thirty is divisible by ten, so the number passes. Changing any single digit changes the sum and fails the check. Digits 4 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 Test number, not a real card. Double every second from the right 8 1 2 1 2 1 2 1 2 1 2 1 2 1 2 1 Subtract 9 from any result over 9 (none here). Sum 8 + 7×2 + 8×1 = 30 Any single-digit change alters this sum. 30 % 10 = 0 Passes. Typo-free as far as Luhn can tell.

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).

Your code or the provider's? A card field built in your own page gives full control over formatting and messages but puts raw card numbers in your code and your compliance scope. A provider's hosted field keeps card numbers out of your page and handles formatting and validation, while you still own the surrounding form, the error summary, focus management and messages mapped from the provider's events. Built in your page Full control of UX. Raw card data in your code and compliance scope. Good for card-like numbers that are not payments. Provider hosted field Card data never touches your page. You own: summary, focus, mapped messages. Default for real payments.

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.


Related

← Input Masking and Formatting