Validating Card Expiry Dates
How do you validate a card expiry field so it accepts every reasonable way people type “April 2029” — 04/29, 4/29, 0429, 04 / 2029 — rejects cards that have genuinely expired, and never rejects a card during its final valid month? This recipe parses the input into a month and year, formats it as MM/YY while typing, compares against the first day of the month after expiry, rejects implausibly distant years, and reports every failure through setCustomValidity() so the field behaves like any other constraint in the Constraint Validation API.
When to Use a Single MM/YY Field
A single text field that mirrors the printed card is the fastest to fill and matches autocomplete="cc-exp", which is what browsers and wallets fill most reliably. Prefer it over two <select> menus when:
- Most users type or autofill, which is true on almost every consumer checkout.
- Your layout is narrow, where two dropdowns plus labels take far more space.
- You want one error location, rather than deciding whether a bad date is the month’s fault or the year’s.
Two separate fields (cc-exp-month and cc-exp-year) still make sense when your payment provider’s API demands them separately and you want autofill to populate them directly, or in kiosk interfaces where selecting is easier than typing. The validation rules below apply to either layout; only the parsing step changes.
Minimal Working Expiry Validator
export interface Expiry { month: number; year: number } // year is four-digit
/** Accepts 0429, 04/29, 4/29, 04-29, 04 / 2029, 4/2029. Returns null if unparseable. */
export function parseExpiry(raw: string): Expiry | null {
const s = raw.normalize("NFKC").replace(/\s+/g, "");
let m: RegExpMatchArray | null;
if ((m = s.match(/^(\d{1,2})[/\-.](\d{2}|\d{4})$/))) {
return build(Number(m[1]), m[2]);
}
if ((m = s.match(/^(\d{2})(\d{2})$/))) return build(Number(m[1]), m[2]); // 0429
return null;
}
function build(month: number, y: string): Expiry | null {
if (month < 1 || month > 12) return null;
const year = y.length === 2 ? 2000 + Number(y) : Number(y);
return { month, year };
}
const MAX_YEARS_AHEAD = 20;
export function expiryError(raw: string, now = new Date()): string {
if (raw.trim() === "") return "Enter the expiry date from your card.";
const exp = parseExpiry(raw);
if (!exp) return "Enter the expiry date as MM/YY, for example 04/29.";
// Valid through the LAST day of the expiry month → compare with the 1st of the next month.
const firstInvalidDay = new Date(exp.year, exp.month, 1); // month index is 0-based, so this is month+1
if (firstInvalidDay <= now) return "This card has expired. Check the date or use a different card.";
if (exp.year > now.getFullYear() + MAX_YEARS_AHEAD) return "Check the expiry year — it looks too far in the future.";
return "";
}
// Field wiring: format while typing, validate on input and submit.
const form = document.querySelector<HTMLFormElement>("#checkout")!;
const field = form.querySelector<HTMLInputElement>("#cc-exp")!;
field.addEventListener("input", (event) => {
const e = event as InputEvent;
const digits = field.value.replace(/\D/g, "").slice(0, 4);
const fourDigitYear = /^\s*\d{1,2}\s*[/\-.]\s*\d{4}\s*$/.test(field.value); // e.g. pasted "04/2029"
// Auto-insert the slash after the month, but never fight a deletion or a full-year paste.
if (!fourDigitYear && e.inputType !== "deleteContentBackward") {
if (digits.length === 1 && Number(digits) > 1) field.value = `0${digits}/`; // "4" → "04/"
else if (digits.length >= 2) field.value = `${digits.slice(0, 2)}/${digits.slice(2)}`;
}
field.setCustomValidity(expiryError(field.value));
});
form.addEventListener("submit", (event) => {
field.setCustomValidity(expiryError(field.value));
if (!form.checkValidity()) {
event.preventDefault();
form.reportValidity();
}
});
The key line is new Date(exp.year, exp.month, 1). JavaScript months are zero-based, so passing the one-based expiry month as the month index lands on the first day of the following month — exactly the first moment the card stops working. Comparing against that avoids the most common expiry bug, where a card expiring this month is rejected for the whole month.
Expiry Validator Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
| Accepted separators | regex class | / - . |
Tolerates the separators people actually type |
| Two-digit year pivot | number |
2000 |
YY becomes 20YY |
MAX_YEARS_AHEAD |
number |
20 |
Rejects typos like 04/92 read as 2092 |
now parameter |
Date |
new Date() |
Injectable for deterministic tests |
| Auto-slash | boolean behaviour | on | Inserts / after two digits, pads 4 to 04/ |
autocomplete |
token | cc-exp |
One-field autofill from browsers and wallets |
maxlength |
number |
7 |
Allows 04/2029 as well as 04/29 |
Verification Steps
import { describe, it, expect } from "vitest";
import { expiryError, parseExpiry } from "./expiry";
const NOW = new Date(2026, 8, 18); // 18 September 2026
describe("expiry", () => {
it("parses common variants", () => {
for (const raw of ["04/29", "4/29", "0429", "04 / 2029", "04-29"]) {
expect(parseExpiry(raw)).toEqual({ month: 4, year: 2029 });
}
});
it("is valid through the last day of the expiry month", () => {
expect(expiryError("09/26", NOW)).toBe("");
expect(expiryError("09/26", new Date(2026, 9, 1))).toMatch(/expired/);
});
it("rejects implausible years", () => {
expect(expiryError("04/92", NOW)).toMatch(/too far/);
});
});
Edge Cases and Failure Modes
Time zones at midnight on the 1st. The browser’s new Date() is local time, while the issuer uses its own. A user just after midnight on 1 October in one zone may still be in 30 September for the issuer. The difference is a few hours on one day per card lifetime; accept it client-side and let the provider’s authorisation be final rather than adding time-zone logic that can only be wrong in new ways.
The auto-slash fights deletion. Without the deleteContentBackward check, pressing Backspace after 04/ removes the slash, the handler sees two digits and puts it straight back, trapping the user. Always skip auto-insertion on delete input types.
Dropdown year lists go stale. If you use separate selects, generate the year options from new Date().getFullYear() at render time. A hard-coded list that ends in 2030 will start rejecting new cards in a few years, silently.
Leading zero for single-digit months. 4/29 must be accepted; people type what they see, and some cards print 4/29. The parser takes one or two month digits for exactly this reason.
Handling Autofill That Splits or Joins the Date
Autofill is where expiry parsing earns its keep. Browsers and wallets decide the format from the autocomplete token, and they do not all agree. Chrome fills a cc-exp field with MM/YY in most locales but has used MM/YYYY when the field’s maxlength allows seven characters; Safari’s card scanner can produce MM/YY or MM / YY with spaces; some password managers ignore cc-exp entirely and fill cc-exp-month and cc-exp-year fields if they exist anywhere on the page, even hidden ones. The tolerant parser above absorbs every one of those shapes, which is the main reason to accept separators, spaces and four-digit years rather than enforcing a strict mask.
Autofill also bypasses the input event’s inputType in some engines: the value arrives with inputType of insertReplacementText or undefined. The formatter treats both as insertions and reformats, which is correct, but it means the whole value is replaced at once — so never assume the user typed character by character, and never compute the new value from the previous value plus event.data. Always derive from the complete current value, as the handler does.
If your provider needs separate month and year values while your UI uses one field, split at submission time rather than keeping hidden inputs in sync on every keystroke. The formdata event lets you append derived fields without adding them to the DOM, so autofill tools never see duplicate targets.
form.addEventListener("formdata", (event) => {
const exp = parseExpiry(field.value);
if (!exp) return; // validation already blocked submission
event.formData.set("exp_month", String(exp.month).padStart(2, "0"));
event.formData.set("exp_year", String(exp.year));
event.formData.delete("cc-exp");
});
The server must still re-validate: formdata runs in the browser, and a hand-crafted request can send any month and year it likes. The same parse function can run in both places if you put it in a shared module, the approach described in sharing Zod schemas in a monorepo.
Styling and Announcing the Format Hint
The format is not obvious to everyone — some regions print MM/YYYY, and some users expect a day. A visible hint (“For example, 04/29”) linked with aria-describedby answers the question before it is asked, and the error message repeats the example so a user who skipped the hint gets it again at the point of failure. Avoid placeholder-only hints: placeholders disappear on the first keystroke and have poor contrast in most themes. The broader guidance on hint placement and wording is in writing clear inline error message copy, and the choice of numeric keypad for this field is covered in choosing inputmode and enterkeyhint.
Frequently Asked Questions
Is a card valid during the month printed as its expiry date?
Yes. A card marked 09/26 can be used until the last day of September 2026. Compare the current date with the first day of the following month, not with the first day of the expiry month.
Should the expiry field be two selects or one text field?
One text field with autocomplete="cc-exp" is faster and autofills well. Use separate month and year selects only when your provider requires separate values or the interface suits selection better than typing.
How should two-digit years be interpreted?
As 2000 plus the value. Then reject years more than about 20 years ahead, which catches typos such as 04/92 without rejecting any real card.
Why does my auto-inserted slash make Backspace stop working?
The input handler re-inserts the slash as soon as it is deleted. Check event.inputType and skip auto-formatting when it is a deletion.
Related Guides
- Payment Card Validation — the full checkout validation pipeline.
- Validating Date Input Min, Max and Format — rules for full dates rather than month-year pairs.
- Validating Date Range: Start Before End — date comparison across two fields.
- Detecting Card Brand and Formatting Input — the caret-safe formatter used for the card number.