Payment Card Validation
Checkout forms are where validation mistakes cost money directly. A card field that rejects a perfectly good number because it contains spaces, an expiry check that fails on the last day of the month, a CVC rule that assumes three digits and locks out every American Express holder, a formatter that jumps the caret to the end when the user fixes a middle digit — each of these shows up as abandoned baskets rather than bug reports. This topic covers client-side validation for the payment fields you control: primary account numbers (PANs), expiry dates, security codes and IBANs for bank transfers. It builds on the site’s baseline of a <form novalidate> and the Constraint Validation API, adding checksum and brand logic through setCustomValidity().
One boundary first. If you use a payment provider’s hosted fields or iframes, the card number never touches your DOM and the provider validates it; you only style their error events. Everything here applies when you render the inputs yourself — for example with a provider’s tokenisation API — or when you validate bank details such as IBANs that providers leave to you. Either way, client-side checks are about catching typos before a round trip, never about deciding whether a card is genuine; only the issuer can do that.
Prerequisites for Payment Field Validation
| Requirement | Minimum version | Why it is needed |
|---|---|---|
| TypeScript | 5.0+ | Literal union types for card brands |
inputmode="numeric" |
All mobile browsers | Numeric keypad without type=number side effects |
autocomplete="cc-number", cc-exp, cc-csc |
All browsers | Card autofill from the browser or wallet |
BigInt |
Chrome 67, Firefox 68, Safari 14 | IBAN mod-97 over 30+ digit numbers |
Intl.DateTimeFormat |
All browsers | Formatting expiry messages in the user’s locale |
| PCI DSS scope review | — | Rendering your own card inputs may widen your compliance scope |
Never use type="number" for card numbers, expiry dates or CVCs. Number inputs drop leading zeros, accept e and -, change the value on scroll-wheel, and render spinners — none of which make sense for identifiers. Use type="text" with inputmode="numeric", which gives the same mobile keypad without the numeric semantics.
Card Field API Reference
| API / attribute | Type | Effect | Notes |
|---|---|---|---|
inputmode="numeric" |
attribute | Numeric virtual keyboard | Does not restrict typed characters on desktop |
autocomplete="cc-number" |
token | Enables card autofill | Also cc-exp, cc-exp-month, cc-exp-year, cc-csc, cc-name |
maxlength |
integer | Caps typed length | Include room for formatting spaces (23 for 19 digits) |
pattern |
regex string | Sets patternMismatch |
Use [\d ]{12,23} as a coarse first filter only |
setCustomValidity() |
(msg) => void |
Sets customError |
Luhn, brand and expiry verdicts |
beforeinput event |
InputEvent |
Fires before the value changes | Reject non-digits without caret jumps |
selectionStart |
number | null |
Caret position | Must be restored after reformatting |
input.dataset.brand |
string |
Your own state | Drives CVC length and the brand icon |
Step-by-Step Implementation
1. Mark up the fields for autofill and the right keyboard
<form id="checkout" novalidate>
<label for="cc-number">Card number</label>
<input id="cc-number" name="cardnumber" type="text" inputmode="numeric"
autocomplete="cc-number" maxlength="23" required
aria-describedby="cc-number-err">
<p id="cc-number-err" class="field-error" hidden></p>
<label for="cc-exp">Expiry date (MM/YY)</label>
<input id="cc-exp" name="cc-exp" type="text" inputmode="numeric"
autocomplete="cc-exp" placeholder="MM/YY" maxlength="7" required
aria-describedby="cc-exp-hint cc-exp-err">
<p id="cc-exp-hint" class="hint">For example, 04/29</p>
<p id="cc-exp-err" class="field-error" hidden></p>
<label for="cc-csc">Security code</label>
<input id="cc-csc" name="cvc" type="text" inputmode="numeric"
autocomplete="cc-csc" maxlength="4" required aria-describedby="cc-csc-hint cc-csc-err">
<p id="cc-csc-hint" class="hint">3 digits on the back of the card</p>
<p id="cc-csc-err" class="field-error" hidden></p>
<button type="submit">Pay</button>
</form>
The expiry hint is a visible example, not only a placeholder — placeholders vanish on input and are not reliably announced, which the writing clear inline error message copy guide covers in detail.
2. Normalise before every check
export const digitsOnly = (raw: string): string => raw.replace(/[\s-]/g, "");
export const isAllDigits = (s: string): boolean => /^\d+$/.test(s);
Users type spaces, paste numbers with dashes from password managers, and occasionally include a trailing space from a copied email. Strip spaces and dashes; reject anything else with a specific message rather than silently removing letters, which would hide a genuine mistake.
3. Detect the brand from the IIN
export type Brand = "visa" | "mastercard" | "amex" | "discover" | "diners" | "jcb" | "unknown";
interface BrandRule { brand: Brand; test: (d: string) => boolean; lengths: number[]; cvc: number; }
const inRange = (d: string, len: number, lo: number, hi: number) => {
const n = Number(d.slice(0, len));
return d.length >= len && n >= lo && n <= hi;
};
export const BRANDS: BrandRule[] = [
{ brand: "amex", test: (d) => /^3[47]/.test(d), lengths: [15], cvc: 4 },
{ brand: "visa", test: (d) => d.startsWith("4"), lengths: [13, 16, 19], cvc: 3 },
{ brand: "mastercard", test: (d) => inRange(d, 2, 51, 55) || inRange(d, 4, 2221, 2720), lengths: [16], cvc: 3 },
{ brand: "discover", test: (d) => d.startsWith("6011") || d.startsWith("65") || inRange(d, 3, 644, 649), lengths: [16, 17, 18, 19], cvc: 3 },
{ brand: "diners", test: (d) => /^3(6|8|0[0-5])/.test(d), lengths: [14, 15, 16, 17, 18, 19], cvc: 3 },
{ brand: "jcb", test: (d) => inRange(d, 4, 3528, 3589), lengths: [16, 17, 18, 19], cvc: 3 },
];
export function detectBrand(digits: string): BrandRule | undefined {
return BRANDS.find((b) => b.test(digits));
}
Detection works on partial input, so the brand icon can appear after the first one to four digits. Treat an unknown brand as “cannot say yet”, not as an error — new ranges appear regularly and your table will always lag the networks. The detailed recipe, including formatting groups per brand, is in detecting card brand and formatting input.
4. Verify with the Luhn checksum
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;
}
The Luhn algorithm catches every single-digit typo and most adjacent transpositions, which covers the vast majority of real keying errors. The walkthrough with a worked example lives in Luhn algorithm credit card validation.
5. Compose the verdict and wire it to the form
export function cardNumberError(raw: string): string {
const d = digitsOnly(raw);
if (d.length === 0) return "Enter your card number.";
if (!isAllDigits(d)) return "Card number can only contain digits and spaces.";
const rule = detectBrand(d);
if (rule && !rule.lengths.includes(d.length)) {
return `${label(rule.brand)} card numbers are ${rule.lengths.join(" or ")} digits long.`;
}
if (!rule && (d.length < 12 || d.length > 19)) return "Card number must be between 12 and 19 digits.";
if (!luhnValid(d)) return "Check your card number — it looks like a digit is wrong.";
return "";
}
const label = (b: Brand) => ({ visa: "Visa", mastercard: "Mastercard", amex: "American Express",
discover: "Discover", diners: "Diners Club", jcb: "JCB", unknown: "Card" })[b];
const form = document.querySelector<HTMLFormElement>("#checkout")!;
const number = form.querySelector<HTMLInputElement>("#cc-number")!;
const csc = form.querySelector<HTMLInputElement>("#cc-csc")!;
number.addEventListener("input", () => {
const d = digitsOnly(number.value);
const rule = detectBrand(d);
number.dataset.brand = rule?.brand ?? "unknown";
csc.maxLength = rule?.cvc ?? 4; // Amex needs 4
document.querySelector("#cc-csc-hint")!.textContent =
rule?.cvc === 4 ? "4 digits on the front of the card" : "3 digits on the back of the card";
number.setCustomValidity(cardNumberError(number.value));
});
form.addEventListener("submit", (event) => {
number.setCustomValidity(cardNumberError(number.value));
if (!form.checkValidity()) {
event.preventDefault();
form.reportValidity();
}
});
The error messages are specific — “American Express card numbers are 15 digits long” tells the user exactly what to count — but deliberately vague about which digit is wrong after a Luhn failure, because the checksum cannot know.
State Management and Edge Cases
Card fields change each other: the number decides the CVC length, and autofill can populate number, expiry and CVC in a single tick. Keep one small function per field that recomputes from the current values, and call all of them from each field’s input handler and from submit. Avoid cached intermediate state such as “the brand we detected earlier”; recompute it, because a user who changes the first digit from 4 to 3 has changed brand and CVC length at the same time.
- Autofill fires
inputonce with a full value. Your formatter must handle a 16-digit value arriving at once, not just one character at a time. - Formatting and caret position. Inserting spaces moves characters, so the caret must be recomputed by counting digits before it; otherwise editing the middle of the number throws the caret to the end.
- Expiry “this month”. A card expiring 09/26 is valid through the last day of September 2026. Compare against the first day of the following month, a subtlety covered in validating card expiry dates.
- Two-digit years. Interpret
YYas20YYand reject dates more than about 20 years in the future as likely typos.
Testing all of this needs numbers that pass Luhn without being real cards. Every provider publishes test PANs — 4242 4242 4242 4242 for Visa, 3782 822463 10005 for American Express — and those are what belong in fixtures, unit tests and Playwright specs. Never put a real card number in a test file or a screenshot, even a cancelled one; repository history is forever and secret scanners flag Luhn-valid 16-digit strings. Generate additional fixtures programmatically instead: take any 15-digit prefix, compute the check digit with the same luhnValid logic run in reverse, and you have an unlimited supply of numbers that exercise the brand and length rules without touching a real account. A table-driven suite then pins each brand’s lengths, the CVC length switch, and the Luhn failure message, which is exactly the shape of test described in unit testing validation logic.
Accessibility Compliance for Payment Fields
Payment fields carry the heaviest cognitive load on most sites, and WCAG 2.2 has several criteria that apply directly. 1.3.5 Identify Input Purpose requires the autocomplete tokens above so browsers and assistive tools can fill and label the fields. 3.3.1 Error Identification and 3.3.3 Error Suggestion require the text messages — “Check your card number” rather than a red outline. 3.3.4 Error Prevention (Legal, Financial, Data) requires that financial submissions be reversible, checked, or confirmed; a review step before charging satisfies it. And 3.3.7 Redundant Entry means a billing address already entered for shipping should be offered again, not retyped — see the WCAG 3.3.7 redundant entry checklist.
Formatting spaces are visual only. Screen readers read “4242 4242 4242 4242” as four groups, which is actually helpful for checking, but make sure the underlying value you submit is normalised, and never insert formatting with aria-hidden spans inside the input — inputs cannot contain markup.
The brand icon that appears as the user types needs a text alternative that is announced once, not on every keystroke. Put the brand name in visually hidden text inside the field’s description and update it only when the brand changes.
let lastBrand = "";
function announceBrand(brand: string): void {
if (brand === lastBrand) return;
lastBrand = brand;
document.querySelector("#cc-brand-text")!.textContent =
brand === "unknown" ? "" : `Card type: ${label(brand as Brand)}`;
}
Common Gotchas and Debugging
Rejecting spaces. pattern="\d{16}" fails every number the user formatted themselves and every autofilled value that includes spaces.
<!-- Before -->
<input pattern="\d{16}" type="number">
<!-- After -->
<input type="text" inputmode="numeric" autocomplete="cc-number" maxlength="23">
Hard-coding 16 digits. Amex is 15, some Visa and Discover cards are 19, and Diners can be 14. Length must come from the brand table.
CVC fixed at three digits. Every Amex customer fails. Derive the CVC length from the detected brand and update the hint text as well as maxLength.
Expiry rejected on the last day. Comparing new Date(2026, 8) (1 September) to today marks a September card expired all month. Compare with the first day of the next month.
// Before: expired throughout its final month
const expired = new Date(2000 + yy, mm - 1) < new Date();
// After: valid through the last day of the expiry month
const expired = new Date(2000 + yy, mm, 1) <= new Date();
Caret jumps while formatting. Setting input.value = formatted without restoring the selection sends the caret to the end, so fixing a middle digit becomes impossible on mobile. Recompute the caret from the digit count before it.
Mapping Provider Declines Back to the Right Field
Client-side checks catch typos; the provider catches everything else, and its answers arrive after submission as error codes. A decline for an incorrect CVC, an expired card or an invalid number should land on the specific field with the same visual and ARIA treatment as a client-side error, so the user does not have to work out which of four fields “Your card was declined” refers to. A generic decline — insufficient funds, suspected fraud, a do-not-honour response — belongs at form level, because no field edit will fix it and the user needs to try another card or contact their bank.
type ProviderCode =
| "incorrect_number" | "invalid_expiry_month" | "invalid_expiry_year" | "expired_card"
| "incorrect_cvc" | "card_declined" | "processing_error";
const FIELD_FOR_CODE: Partial<Record<ProviderCode, string>> = {
incorrect_number: "cc-number",
invalid_expiry_month: "cc-exp",
invalid_expiry_year: "cc-exp",
expired_card: "cc-exp",
incorrect_cvc: "cc-csc",
};
const MESSAGE_FOR_CODE: Record<ProviderCode, string> = {
incorrect_number: "Your card number is incorrect. Check it and try again.",
invalid_expiry_month: "The expiry month is not valid.",
invalid_expiry_year: "The expiry year is not valid.",
expired_card: "This card has expired. Use a different card.",
incorrect_cvc: "The security code does not match this card.",
card_declined: "Your card was declined. Try a different card or contact your bank.",
processing_error: "We could not process the payment. Please try again in a moment.",
};
export function applyProviderError(form: HTMLFormElement, code: ProviderCode): void {
const fieldId = FIELD_FOR_CODE[code];
const message = MESSAGE_FOR_CODE[code] ?? MESSAGE_FOR_CODE.processing_error;
if (fieldId) {
const field = form.querySelector<HTMLInputElement>(`#${fieldId}`)!;
field.setCustomValidity(message);
field.reportValidity(); // focus + announce, same as a client error
// Clear on the next edit so the user is not stuck with a stale server verdict.
field.addEventListener("input", () => field.setCustomValidity(""), { once: true });
} else {
showFormLevelError(form, message); // an alert region at the top of the form
}
}
The once: true listener is the important detail. A server-originated custom error is not recomputed by your client-side rules, so without it the field stays invalid forever even after the user types a new security code. The general version of this mapping, for any API that returns field-keyed errors, is covered in mapping server field errors to form inputs, and the post-submit focus rules follow managing focus after validation failure.
Keep the raw provider code out of the message. “incorrect_cvc” means nothing to a customer, and some codes — particularly fraud-related ones — should never be exposed verbatim because they tell an attacker which checks they tripped. Log the code server-side; show the human sentence.
Browser Compatibility Matrix
| Feature | Chromium | Firefox | Safari | Notes |
|---|---|---|---|---|
inputmode="numeric" |
Yes | Yes | Yes | Desktop keyboards unaffected |
autocomplete="cc-*" |
Yes | Yes | Yes | Safari scans card with camera on iOS |
beforeinput + getTargetRanges |
Yes | Yes | Yes | Use for rejecting non-digits |
BigInt for IBAN mod-97 |
67+ | 68+ | 14+ | Or chunked modulo for older engines |
| Payment Request API | Yes | Behind flag | Apple Pay only | Bypasses manual card entry entirely |
Where a wallet is available, the fastest checkout avoids card fields altogether. The Payment Request API and platform wallets pass a tokenised card straight to your provider; manual entry and all the validation on this page then become the fallback path. For bank transfers rather than cards, the equivalent checksum problem is covered in IBAN validation with mod 97.
Frequently Asked Questions
Does passing the Luhn check mean a card number is real?
No. Luhn only detects typing mistakes; plenty of valid-looking numbers belong to no account. Only the issuer, via your payment provider, can confirm that a card exists and can be charged.
Should card number inputs use type number?
No. Number inputs strip leading zeros, accept exponents, change on scroll and show spinners. Use type="text" with inputmode="numeric" and autocomplete="cc-number".
How long can a card number be?
Between 12 and 19 digits depending on the brand: 15 for American Express, 16 for most Visa and Mastercard cards, and up to 19 for some Visa, Discover and JCB cards. Validate length against the detected brand.
Is validating card numbers in my own inputs a PCI concern?
Rendering and reading raw card numbers in your own page generally widens your PCI DSS scope compared with provider-hosted fields. Many teams use hosted fields and only style the provider's validation events; check with your provider and assessor.
Related Guides
- Luhn Algorithm Credit Card Validation — the checksum, step by step.
- Detecting Card Brand and Formatting Input — IIN detection and caret-safe formatting.
- Validating Card Expiry Dates — MM/YY parsing and end-of-month rules.
- IBAN Validation with Mod 97 — the bank-transfer counterpart to card checks.
- Handling Server Validation Errors After Submit — when the provider declines what the browser accepted.
← Back to Validating Common Input Types