Card expiry dates: formats, validation and edge cases
The expiry date looks like the simplest field on a payment form: two digits, a slash, two more digits. Yet it causes a steady trickle of wrongly rejected cards, mostly at month ends and around time zones. This guide covers what the date means, the formats you will meet, robust parsing and validation code, and how gateways treat expiry dates in test mode.
Key takeaways
- A card is normally valid through the last day of the printed month, so compare months, not days.
- Accept MM/YY, MM/YYYY, MMYY and stray spaces; normalize to a month and a four-digit year before validating.
- Judge “expired” generously — using the last time zone on Earth (UTC−12) — and let the issuer make the final call.
- Use
autocomplete="cc-exp"(orcc-exp-monthandcc-exp-year) so autofill gets it right. - In sandboxes, the expiry date rarely decides the outcome; most gateways use a dedicated card or value to simulate an expired card.
On this page
Every payment card carries an expiry date, and every checkout asks for it. Getting the field right is mostly about being forgiving: accept the many ways people type a date, normalize it, and only reject what is certainly wrong. The issuer will check the date against the account anyway; your form’s job is to catch typos and impossible values without turning away a valid card.
What the expiry date on a card means
The date printed on a card — labelled “VALID THRU”, “GOOD THRU”, “EXPIRES END” or simply MM/YY — is the last month in which that physical card, and its card number and security code combination, may be used. Issuers commonly state that a card is valid through the last day of the printed month. U.S. Bank, for example, says all of its credit and debit cards are valid through the last day of the month shown, so a card marked 05/30 works until 31 May 2030.
Why cards expire at all
Expiry dates give issuers a regular point to replace worn cards, roll out new chip and contactless features, and refresh the security data printed on the card. When a card is renewed, the number often stays the same but the expiry date changes. Because the issuer derives the security code from the number, expiry date and other data, the CVV usually changes too — one reason stored card details go stale.
The account outlives the card
The expiry date belongs to the plastic, not to the credit line or bank account behind it. An expired card does not mean a closed account; it means the customer has a newer card with a different expiry date (and probably a different security code) in their wallet.
Formats you will meet
The same date appears in several shapes depending on where it comes from. Your code should accept the human formats and convert them into one internal representation, usually an integer month and a four-digit year.
| Where | Format | Example for December 2029 |
|---|---|---|
| Printed on the card | MM/YY | 12/29 |
| What customers type | MM/YY, MM / YY, MMYY, MM/YYYY, M/YY, MM-YY | 12 / 29, 1229, 12/2029 |
| Magnetic stripe track data | YYMM | 2912 |
HTML autofill cc-exp (canonical) | Month string YYYY-MM | 2029-12 |
HTML cc-exp-month / cc-exp-year | Integer 1–12 / full year | 12 / 2029 |
| Most gateway APIs | Separate month and year fields | exp_month=12, exp_year=2029 |
The YYMM order used in card data is a classic source of bugs when people parse raw track data by hand: 2912 is December 2029, not month 29. If you ever see a “month” above 12, check whether the two halves have been swapped.
Designing the expiry input
One field or two?
A single text field with an automatic slash (MM / YY) is the fastest to fill and matches what is printed on the card. Two dropdowns for month and year are slower but prevent impossible values. Both work; what matters is that the field cooperates with autofill and accepts what people actually type.
Autofill attributes
The HTML standard defines three autofill tokens for this field:
autocomplete="cc-exp"for a single field. Its canonical format is a valid month string such as2014-12, so normalize whatever ends up in the field rather than trusting a fixed layout.autocomplete="cc-exp-month"for a month field, defined as an integer from 1 to 12.autocomplete="cc-exp-year"for a year field, defined as a positive integer, with a four-digit year as the example. If your year field only allows two digits, make sure a filled-in2029becomes29rather than being cut to20.
Use inputmode="numeric" on a single text field so phones show a number pad, and avoid type="number", which drops leading zeros and cannot hold a slash. Set maxlength generously (7 characters covers MM / YY) and insert the slash for the user rather than rejecting input without one.
Parsing what people type
Normalize first: remove spaces, accept a slash or dash as the separator, and allow a one-digit month only when a separator makes it unambiguous. Without a separator, require two digits for the month, so 1229 is read as 12/29. Two-digit years become 20YY — no card in circulation today carries a 19xx date, so there is no century ambiguity to resolve for the foreseeable future.
// Accepts "12/29", "12 / 29", "1/29", "12-29", "1229", "12/2029" and "122029".
function parseExpiry(input) {
const s = String(input).replace(/\s+/g, '');
const m = s.match(/^(0?[1-9]|1[0-2])[\/-]([0-9]{2}|[0-9]{4})$/) ||
s.match(/^(0[1-9]|1[0-2])([0-9]{2}|[0-9]{4})$/);
if (!m) return null;
const month = Number(m[1]);
let year = Number(m[2]);
if (year < 100) year += 2000;
return { month, year };
}
parseExpiry('12 / 29'); // { month: 12, year: 2029 }
parseExpiry('13/29'); // nullThe same logic on the server, where you should validate again no matter what the browser did:
import re
from datetime import datetime, timedelta, timezone
EXPIRY = re.compile(r'(0?[1-9]|1[0-2])[/-]([0-9]{2}|[0-9]{4})|(0[1-9]|1[0-2])([0-9]{2}|[0-9]{4})')
UTC_MINUS_12 = timezone(timedelta(hours=-12))
def parse_expiry(text: str) -> tuple[int, int] | None:
m = EXPIRY.fullmatch(re.sub(r'\s+', '', text))
if not m:
return None
month, year = (m[1], m[2]) if m[1] else (m[3], m[4])
year = int(year)
return int(month), year + 2000 if year < 100 else year
def is_expired(month: int, year: int, now: datetime | None = None) -> bool:
local = (now or datetime.now(timezone.utc)).astimezone(UTC_MINUS_12)
return (year, month) < (local.year, local.month)Is it expired? Month ends and time zones
Because a card is normally valid through the last day of its month, the correct comparison is by month: a card is expired only when its (year, month) is earlier than the current (year, month). Comparing full dates — for example treating 12/29 as 1 December 2029 — rejects every customer whose card expires this month.
Whose “now”?
Month boundaries happen at different moments around the world. At 02:00 UTC on 1 November it is already November in Europe and Asia, but still 31 October across North America and most of South America, and a card marked 10/26 is still valid for those customers. If your server simply uses its own UTC clock, it will reject them for several hours every month.
The safe rule is to validate in the cardholder’s favor: compute the current month in the last time zone to finish a day, UTC−12, and only call a card expired once the month has ended there. The worst case is that you send an authorization for a card that expired a few hours ago — and the issuer declines it, which is the correct outcome anyway.
// Expired only once the card's month has ended everywhere (UTC-12 is the last zone to finish a day).
function isExpired(exp, now = new Date()) {
const lagging = new Date(now.getTime() - 12 * 60 * 60 * 1000);
const y = lagging.getUTCFullYear();
const m = lagging.getUTCMonth() + 1;
return exp.year < y || (exp.year === y && exp.month < m);
}
isExpired({ month: 10, year: 2026 }, new Date('2026-11-01T02:00:00Z')); // false: still October in UTC-12
isExpired({ month: 10, year: 2026 }, new Date('2026-11-01T13:00:00Z')); // trueFront end vs. back end
In the browser you can use the customer’s local date, which is usually the right one for their card. On the server, use the UTC−12 rule above. Never let the two disagree in the strict direction, or customers will see a form that accepts their card and a server that rejects it.
How far in the future is plausible?
Cards are issued with a validity of a few years, so a date decades away is almost certainly a typo — for example a year of 2092 instead of 2029. Many forms cap the year at the current year plus 20, which comfortably covers every real card while still catching obvious mistakes. If you use dropdowns, the same cap decides how many years to list.
function isTooFarAhead(exp, now = new Date(), maxYears = 20) {
return exp.year > now.getUTCFullYear() + maxYears;
}Keep the cap loose. A slightly-too-generous limit costs nothing because the issuer validates the real date; a too-tight one rejects legitimate cards and is hard to notice in your metrics.
Expiry dates in your backend
Storage and PCI DSS
The expiry date is cardholder data under PCI DSS. If you store it together with the card number, it is in scope and must be protected like the rest of the record. Most merchants avoid the problem entirely by letting the payment provider tokenize the card and storing only the token, the brand, the last four digits and the expiry month and year for display. Our guide to test data and PCI DSS covers keeping real card data out of non-production systems.
Cards on file and renewals
For subscriptions, the expiry date you stored will eventually pass. Don’t block a renewal charge just because your copy of the date is in the past: many gateways and networks update stored cards automatically through account updater services and network tokens, and the issuer may still approve the charge. Use the stored date to remind customers to update their card, not as a hard gate.
Testing expiry handling in gateway sandboxes
In sandboxes, the expiry date rarely drives the result. Most gateways accept any future date and simulate an expired card through a dedicated card number or trigger value, so your test plan needs both.
| Gateway | Expiry date to use | Simulating an expired card |
|---|---|---|
| Stripe | Any future date, e.g. 12/34 | Card 4000 0000 0000 0069 returns expired_card |
| Braintree | Any future date; 3-D Secure test cards need month 01 and the current year + 3 | Amount-based processor responses (see the gateway page) |
| Square | Any future month and year | Expiry 01/40 returns “card expiration date incorrect” |
| Adyen | The exact date listed per card, 03/2030 for most | Documented refusal triggers (see the gateway page) |
Before the request ever reaches a sandbox, test your own parsing and validation. Our card generator lets you pick a fixed expiry month and year or random future dates, so you can produce cards that expire this month, next month and years ahead. Add these cases to your suite:
- The current month and year (must pass).
- Last month (must fail), and the first hours of a new month in UTC (must still pass on the server).
00/29,13/29,1/2and letters (must fail with a clear message).12 / 29,1229,12/2029and an autofilled2029-12if you support it (must normalize to the same value).- A year beyond your cap, such as current year + 30 (must fail as a likely typo).
Pair these with the card number checks and the security code rules, and the whole card form is covered before you spend a single sandbox request.
Frequently asked questions
Is a card valid until the first or the last day of the expiry month?
Almost always the last day. A card printed 05/30 can normally be used until 31 May 2030. Issuers state this in their own help pages, but the authorization decision is theirs, so treat the rule as a guide for form validation rather than a guarantee.
Should I accept a card that expires this month?
Yes. A card with the current month and year has not expired yet. Rejecting it is one of the most common validation bugs.
How do I convert a two-digit year?
Add 2000. Payment cards printed today never carry a 19xx year, so 29 means 2029. If your field also accepts four digits, use them as typed.
What is the HTML autocomplete value for the expiry date?
cc-exp for a single field, or cc-exp-month and cc-exp-year for two fields. The HTML standard defines the canonical format of cc-exp as a month string such as 2014-12.
Do test cards need a real expiry date?
Most sandboxes accept any future date. Some are stricter: Adyen’s test cards use the specific dates listed on its test card page (03/2030 for most), and Braintree’s 3-D Secure test cards need month 01 of the current year plus three.
Does the CVV change when a card is renewed?
Usually, yes. The issuer derives the security code from the card number and expiry date among other data, so a renewed card with a new expiry date normally gets a new code even if the number stays the same.
Sources
Tools used in this guide
Related guides
Written for developers, QA teams and merchants. Generated card numbers are test data: they are not real cards and cannot pay for anything.