Card expiry date validation

A card is good until the last instant of its printed month, so 09/26 is live all through September 2026. Run node expiry.js 2026-09-12T10:00:00Z end and confirm the current month is accepted. An implementation that compares against the first of that month rejects a working card for up to 30 days.

Why check this

This runs on the checkout form, on the stored-card screen and on any API that accepts a card. Do it whenever the validation is touched, and once in every regression pass, because the bug only appears during one month of the year for one group of cardholders. The failure it prevents is a customer whose card expires this month being told the card is expired while it still authorises.

Prerequisites

const now = new Date(process.argv[2]);
const mode = process.argv[3] || 'end';

function check(input) {
  const m = /^(\d{1,2})\s*\/\s*(\d{2}|\d{4})$/.exec(input.trim());
  if (!m) return 'rejected, not MM/YY or MM/YYYY';
  const month = Number(m[1]);
  if (month < 1 || month > 12) return `rejected, month ${m[1]} is not 1 to 12`;
  const year = m[2].length === 2 ? 2000 + Number(m[2]) : Number(m[2]);
  const boundary = mode === 'end' ? new Date(year, month, 1) : new Date(year, month - 1, 1);
  if (now >= boundary) return 'expired';
  return `accepted, last valid moment ${new Date(boundary - 1).toISOString()}`;
}

const cases = ['08/26', '09/26', '10/26', '09/2026', '9/26', '00/26', '13/26', '26/09', '12/34', '09/46', ' '];
console.log(`now   ${now.toISOString()}`);
console.log(`zone  ${Intl.DateTimeFormat().resolvedOptions().timeZone}, offset ${-now.getTimezoneOffset()} minutes`);
console.log(`mode  ${mode}`);
for (const c of cases) console.log(`  ${JSON.stringify(c).padEnd(9)} ${check(c)}`);

Steps

  1. Step 1.

    Run the boundary table with the card treated as live to the end of its month.

    node expiry.js 2026-09-12T10:00:00Z end
    
    now   2026-09-12T10:00:00.000Z
    zone  Europe/Kiev, offset 180 minutes
    mode  end
    "08/26"   expired
    "09/26"   accepted, last valid moment 2026-09-30T20:59:59.999Z
    "10/26"   accepted, last valid moment 2026-10-31T21:59:59.999Z
    "09/2026" accepted, last valid moment 2026-09-30T20:59:59.999Z
    "9/26"    accepted, last valid moment 2026-09-30T20:59:59.999Z
    "00/26"   rejected, month 00 is not 1 to 12
    "13/26"   rejected, month 13 is not 1 to 12
    "26/09"   rejected, month 26 is not 1 to 12
    "12/34"   accepted, last valid moment 2034-12-31T21:59:59.999Z
    "09/46"   accepted, last valid moment 2046-09-30T20:59:59.999Z
    " "       rejected, not MM/YY or MM/YYYY

    The reference instant is the twelfth of September. 09/26 is accepted, and the last valid moment is the end of the month. 26/09 is rejected because 26 is not a month, which is the only thing that separates a swapped field order from a valid entry.

  2. Step 2.

    Run the same table against the other reading, where the card is compared with the first of its printed month.

    node expiry.js 2026-09-12T10:00:00Z start
    
    now   2026-09-12T10:00:00.000Z
    zone  Europe/Kiev, offset 180 minutes
    mode  start
    "08/26"   expired
    "09/26"   expired
    "10/26"   accepted, last valid moment 2026-09-30T20:59:59.999Z
    "09/2026" expired
    "9/26"    expired
    "00/26"   rejected, month 00 is not 1 to 12
    "13/26"   rejected, month 13 is not 1 to 12
    "26/09"   rejected, month 26 is not 1 to 12
    "12/34"   accepted, last valid moment 2034-11-30T21:59:59.999Z
    "09/46"   accepted, last valid moment 2046-08-31T20:59:59.999Z
    " "       rejected, not MM/YY or MM/YYYY

    One line changed in the validator. 09/26 is now expired, while 10/26, 12/34 and 09/46 are still accepted and the four rejections are unchanged. A suite built from far-future dates passes both runs and reports nothing.

  3. Step 3.

    Move the reference instant to the month boundary and run it in the machine zone.

    node expiry.js 2026-09-30T22:30:00Z end
    
    now   2026-09-30T22:30:00.000Z
    zone  Europe/Kiev, offset 180 minutes
    mode  end
    "08/26"   expired
    "09/26"   expired
    "10/26"   accepted, last valid moment 2026-10-31T21:59:59.999Z
    …

    Local time is already the first of October, so the September card has expired.

  4. Step 4.

    Run the same instant with the process in another zone. This is PowerShell, for the reason in Prerequisites.

    $env:TZ = 'America/New_York'; node expiry.js 2026-09-30T22:30:00Z end
    
    now   2026-09-30T22:30:00.000Z
    zone  America/New_York, offset -240 minutes
    mode  end
    "08/26"   expired
    "09/26"   accepted, last valid moment 2026-10-01T03:59:59.999Z
    "10/26"   accepted, last valid moment 2026-11-01T03:59:59.999Z
    …

    The same instant, the same card, the opposite verdict. The two zones are 7 hours apart, and for those 7 hours the card is expired on one server and live on another. Decide which clock owns the boundary, write it down, and pin the zone in the test rather than inheriting it from the build machine.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Current month accepted | The card is live to the end of its printed month | Correct. Keep a test whose date is the current month, generated at run time. | | Current month expired | Off by one month | Compare with the first of the following month, not the first of the printed one. | | 09/2026 treated differently from 09/26 | The four-digit year is parsed by a different branch | Normalise the year before the comparison, then run both forms through one path. | | 00/26 or 13/26 accepted | The month is not range checked | Reject anything outside 1 to 12 before any date object is built. | | 26/09 accepted | Month and year were read in the wrong order | The range check is what catches this. There is no other signal. | | Two servers disagree at midnight | The boundary is built in local time | Fix the zone the comparison runs in and state it in the contract. |

Common mistakes

Sign: Expiry tests pass and cardholders in their card's final month are refused.Cause: The test data is a far-future date such as 12/34, which both readings of the month accept. The case that separates them is the month the run happens in, so the expected value has to be generated from the clock rather than typed into a fixture.
Sign: A card that works in the sandbox is rejected by the form.Cause: The month was compared as a string, or the two-digit year was read as the year 26 rather than 2026. Both produce a date far in the past. Parse the two fields as integers, add 2000 to a two-digit year, and build the boundary from those numbers.
Sign: The same card is expired on one server and live on another.Cause: The boundary was built in local time, so each server answers from its own zone. Step 4 shows a 7 hour window where the two disagree. Fix the zone the comparison runs in and pin it in the test, because a build machine inherits whatever it was installed with.
Sign: The form accepts an expiry twenty years away.Cause: No issuer prints one. A far-future date is a sign of a bot filling the form or of a test fixture reaching production, so an upper bound belongs beside the lower one. Set it from the longest term the issuers you accept actually use.

What to check next

FAQ

What is card expiry date validation?

Two checks. The format check reads MM/YY or MM/YYYY and rejects a month outside 1 to 12. The date check compares the end of that month with the current instant. Only the second one depends on when the test runs.

Does a card expire at the start or the end of the month?

The end. A card printed 09/26 is good through the last moment of September 2026. Comparing with the first of September instead rejects it for the whole month, which is up to 30 days of refused payments from working cards.

How should a two-digit year be read?

Add 2000 to it. Cards are not issued with expiry dates in the 1900s, and no issuer prints a term long enough to reach 2100. Accept the four-digit form as well, and send both through one comparison so they cannot drift apart.

Why does a test with 12/34 never catch an expiry bug?

Because both readings of the month accept it. Step 2 shows the off-by-one rejecting 09/26 while 12/34 stays accepted. The only case that separates them is a date in the month the test is running in.

Should a form reject an expiry twenty years out?

Yes, with an upper bound taken from the terms the issuers you accept actually use. 09/46 is accepted in every run above, and no issuer prints it. An upper bound also catches a fixture date that escaped into production.

Verified

Verified by Maks Vernynode 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic10 minpublished updated Maks Verny