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
- Node 22, or any language with a date library. No provider account and no network call.
- Save the validator as
expiry.js. Themodeargument switches between the two readings of the printed month, and the reference instant is an argument so the run is repeatable:
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 3 and 4 set the zone for one process. In PowerShell that is
$env:TZ = 'America/New_York'. The inline formTZ=America/New_York node expiry.jsdoes nothing in Git Bash on Windows: the process keeps the machine zone and prints no warning, so the run looks like a program that ignores the variable. - The runs below were made on a machine in
Europe/Kiev, which was UTC+3 on the date shown.
Steps
- 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 endnow 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/YYYYThe reference instant is the twelfth of September.
09/26is accepted, and the last valid moment is the end of the month.26/09is rejected because 26 is not a month, which is the only thing that separates a swapped field order from a valid entry. - 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 startnow 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/YYYYOne line changed in the validator.
09/26is now expired, while10/26,12/34and09/46are still accepted and the four rejections are unchanged. A suite built from far-future dates passes both runs and reports nothing. - 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 endnow 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.
- 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 endnow 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
What to check next
- Luhn check: the field next to this one, and the reason "valid card" is the wrong message on either of them.
- How to test timezone handling: the general form of the disagreement in step 4.
- How to check timezone stored in database: where a stored expiry loses its zone.
- How to test duplicate payment prevention: the other checkout check that a sequential test reports as passing.
- Check a test card number: reads the number, and deliberately says nothing about expiry, because expiry is a question about a clock.
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.
Related on this site
basic10 minpublished updated Maks Verny