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.

Single MM/YY field versus month and year selects Two columns comparing a single typed expiry field with separate month and year dropdowns. Single MM/YY field • autocomplete="cc-exp" ✓ matches the card: type what you see ✓ one field, one error message ✗ needs a tolerant parser Month + year selects • autocomplete cc-exp-month and cc-exp-year ✓ no parsing, no format errors ✗ twice the interactions on mobile ✗ year list must be kept current
The single field matches the printed card and autofills in one step; separate selects trade speed for no parsing.

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.

When a 09/26 card stops being valid A timeline across August to October 2026 showing that a card marked 09/26 is valid through 30 September and only becomes expired on 1 October. Card 09/26 valid expired Buggy check valid rejected Key dates 1 Sep 1 Oct 0 30 60 90 days from 1 Aug 2026
A card printed 09/26 works until the end of September; the comparison point is the first day of October, not the first day of September.

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.

Expiry field in its expired state A payment form with the expiry field showing 08/26 and an error saying the card has expired, with the hint and ARIA wiring annotated. Payment details Card number 4242 4242 4242 4242 Expiry date (MM/YY) 08/26 1 ✗ This card has expired. Check the date or use a different card. Security code ••• Pay 1 aria-describedby="cc-exp-hint cc-exp-err" so the example and the error are both read 2 inputmode="numeric" gives a keypad; type stays text so the slash is allowed 3 The month after expiry is the comparison point, so 09/26 is still valid on 30 September
The hint stays visible next to the error, and the error explains what to do rather than only stating the problem.

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.

← Back to Payment Card Validation