Most card brand regexes on the internet are out of date. They were written before Mastercard added the 2221–2720 range in 2017, before Visa issued 19-digit numbers, and before UnionPay started using the 81 prefix. A regex from a 2013 Stack Overflow answer will reject cards that are in wallets today.
Here are current patterns, each executed against boundary values, plus the detection order that matters and the cases where a regex is the wrong tool.
The patterns
const CARD_PATTERNS = {
visa: /^4\d{12}(?:\d{3})?(?:\d{3})?$/,
mastercard: /^(?:5[1-5]\d{4}|222[1-9]\d{2}|22[3-9]\d{3}|2[3-6]\d{4}|27[01]\d{3}|2720\d{2})\d{10}$/,
amex: /^3[47]\d{13}$/,
discover: /^(?:6011\d{12}|65\d{14}|64[4-9]\d{13}|622(?:12[6-9]|1[3-9]\d|[2-8]\d\d|9[01]\d|92[0-5])\d{10})(?:\d{3})?$/,
jcb: /^35(?:2[89]|[3-8]\d)\d{12,15}$/,
diners: /^3(?:0[0-5]\d{11,16}|095\d{10,15}|[689]\d{12,17})$/,
unionpay: /^(?:62|81)\d{14,17}$/,
maestro: /^(?:5[06-9]|6\d)\d{10,17}$/,
troy: /^9792\d{12}$/,
};
Each pattern encodes both the prefix range and the permitted lengths, which is why they are longer than the prefix-only versions you usually see. The lengths are not decoration — they differ by network, and a pattern that ignores them will match a truncated number as readily as a complete one.
The trailing (?:\d{3})? on Visa and Discover is the 19-digit case. It is easy to leave
out and expensive to leave out, as the test suite below demonstrates.
Reading each pattern
Visa — everything beginning with 4, at 13, 16 or 19 digits. The two optional
three-digit groups are the 16 and 19-digit forms; the base 4\d{12} is the legacy
13-digit format that most patterns forget exists.
The Visa format guide covers why Visa is the only
network identifiable from a single digit.
Mastercard — the 51–55 block plus the decomposed 2221–2720 range, each alternative
written to six digits so that a single \d{10} tail fixes the total at 16. Mastercard uses
no other length, which is why this is the only pattern here with a fixed tail.
American Express — 34 or 37, always 15 digits. The simplest pattern on the page and
the one most likely to be broken by surrounding code, since a 15-digit number and a
four-digit security code both violate the assumptions of a form built around 16 and 3.
Discover — four separate ranges that have accumulated over time: 6011, 65,
64[4-9], and the 622126–622925 block, which is itself another numeric range needing
decomposition. The optional trailing group covers 19 digits.
JCB — 3528 through 3589, expressed as 2[89] or [3-8]\d after the leading 35,
at 16 to 19 digits.
Diners Club — three branches for 300–305, 3095, and 36/38/39, spanning 14 to
19 digits. The 14-digit form is the shortest PAN in common circulation and the one that
minimum-length rules exclude by accident.
UnionPay — 62 or 81, 16 to 19 digits. Worth remembering that some UnionPay ranges
are not Luhn-valid, so a checksum failure on these prefixes should warn rather than block.
Maestro — 50, 56–69, and 12 to 19 digits, which is the broadest pattern here in
both dimensions. That breadth is exactly why it goes last.
Troy — 9792 at 16 digits. The narrowest pattern, because the scheme was allocated a
single four-digit block under the MII reserved for national standards bodies.
Why Mastercard is the ugly one
A numeric range cannot be expressed as a prefix match, so 2221–2720 has to be decomposed:
| Range | Pattern piece | Why |
|---|---|---|
| 2221–2229 | 222[1-9] | Lower boundary |
| 2230–2299 | 22[3-9]\d | |
| 2300–2699 | 2[3-6]\d\d | Bulk of the range |
| 2700–2719 | 27[01]\d | |
| 2720 | 2720 | Upper boundary |
Five alternatives for one contiguous range. Anyone who writes ^2[2-7] instead is
accepting 2200–2799, which includes numbers Mastercard has not been assigned — and
the Mastercard page has test numbers for checking the edges.
Detection order matters
Several ranges overlap, so the first pattern that matches wins and the order determines what you get:
| Overlap | Networks | Resolution |
|---|---|---|
65 | Discover, Maestro | Discover first — its ranges are specific |
64[4-9] | Discover, Maestro | Same |
622126–622925 | Discover, UnionPay | A historical partnership; a business rule decides |
6… broadly | Maestro, Discover, UnionPay | Maestro last — it is the catch-all |
The rule is most specific first, broadest last:
const DETECTION_ORDER = [
'amex', 'visa', 'mastercard', 'troy', // unambiguous prefixes
'discover', 'jcb', 'diners', 'unionpay', // specific ranges
'maestro', // broad catch-all, last
];
function detectBrand(input) {
const pan = String(input).replace(/\D/g, '');
for (const brand of DETECTION_ORDER) {
if (CARD_PATTERNS[brand].test(pan)) return brand;
}
return null;
}
Order is not a style preference here. With Maestro placed earlier, 6011… resolves to
Maestro and every Discover card in your system is mislabelled.
Progressive detection while typing
While someone is still typing there is no complete number, so the full patterns match nothing. Prefix-only patterns are a separate set:
const PREFIX_PATTERNS = {
amex: /^3[47]/,
visa: /^4/,
mastercard: /^(5[1-5]|222[1-9]|22[3-9]|2[3-6]|27[01]|2720)/,
troy: /^9792/,
discover: /^(6011|65|64[4-9]|622)/,
jcb: /^35(2[89]|[3-8])/,
diners: /^3(0[0-5]|095|[689])/,
unionpay: /^(62|81)/,
maestro: /^(5[06-9]|6)/,
};
function detectBrandFromPrefix(input) {
const pan = String(input).replace(/\D/g, '');
if (pan.length < 1) return null;
for (const brand of DETECTION_ORDER) {
if (PREFIX_PATTERNS[brand].test(pan)) return brand;
}
return null;
}
The useful property of this set is that it returns null while the input is still
ambiguous, which is exactly the behaviour you want:
| Typed so far | Result | Why |
|---|---|---|
2 | null | Could be Mastercard; not enough digits to know |
222 | null | Still could fall below 2221 |
2223 | mastercard | Now determined |
5 | null | Mastercard needs 5[1-5], Maestro needs 5[06-9] |
51 | mastercard | |
62 | unionpay | Discover’s overlapping range needs 622 |
6011 | discover |
Show the brand logo as early as you can, and never while it is ambiguous. Displaying the wrong logo and then swapping it is worse than showing nothing: the flicker reads as a bug, and on the one field where users are already nervous about typos, it invites them to re-check a number that was fine.
When a regex is the wrong tool
Being clear about the limits is what separates a working detector from a source of subtle bugs:
- Debit or credit — not encoded in the number at all. That needs a BIN database, which is what BIN lookup is for.
- Country of issue — same answer. The BIN and IIN guide explains what the prefix does and does not identify.
- Whether the card exists — a regex checks shape. Existence is a question only the issuing bank answers.
- New ranges — a regex is static and networks add ranges. This is maintenance work, not a one-time task.
- Anything that affects money — your payment provider returns the brand it actually routed the card as. Use your regex for the interface and the provider’s answer for business logic. When the two disagree, the provider is right and your pattern has aged.
That last point is the architectural one. Detection in the browser is a display concern; treating it as a source of truth means a stale pattern can misprice a transaction or route it to the wrong acquirer, and the failure is silent because the card still works.
Three regex mistakes specific to this problem
Forgetting the anchors. Without ^ and $ these patterns match a substring, so a
19-digit number matches the 16-digit Visa pattern at its prefix and any longer garbage
string containing a valid-looking run matches too. Every pattern on this page is anchored
at both ends deliberately, and removing either anchor turns a length check into no check.
Testing before stripping. Users paste 4539 1488-0343 6467. Run
replace(/\D/g, '') first, always, and do it in one place rather than at each call site —
a pattern that works in your unit test and fails on real input is nearly always this.
Building patterns by string concatenation. Assembling a range from user-supplied or configuration data invites both injection and catastrophic backtracking. These patterns have no nested quantifiers and cannot backtrack pathologically; a generated one might. Keep them as literals.
Maintenance
- Review the patterns annually. Ranges change rarely and consequentially.
- Log every disagreement between your detection and the brand your provider reports. That log is your early-warning system, and it costs one line.
- Never hard-reject an unrecognised prefix. Accept it, let the provider decide, and treat
nullas “unknown” rather than “invalid” — new BIN ranges appear before your regex learns about them. - Keep the test suite next to the patterns in the same file. A pattern edited without its boundary cases re-run is the specific change that ships this class of bug, and the suite takes milliseconds.
The failure mode worth guarding against is not a pattern that breaks loudly. It is one that keeps working for the 95% of cards you see daily while quietly misclassifying the rest, which is why the boundary cases matter more than the happy-path ones.
The full test suite
Copy this alongside the patterns. Every case below was executed before this page was published, including the boundary cases either side of each range:
const TESTS = [
['4539148803436467', 'visa'],
['4222222222222', 'visa'], // 13-digit
['4532015112830366187', 'visa'], // 19-digit
['5425233430109903', 'mastercard'],
['2223003122003222', 'mastercard'], // 2-series
['2221000000000000', 'mastercard'], // lower boundary
['2720999999999999', 'mastercard'], // upper boundary
['2220000000000000', null], // below 2221
['2721000000000000', null], // above 2720
['374245455400126', 'amex'],
['378282246310005', 'amex'],
['6011111111111117', 'discover'],
['6011000000000000000', 'discover'], // 19-digit
['3530111333300000', 'jcb'],
['3528000000000000', 'jcb'], // lower boundary
['3589000000000000', 'jcb'], // upper boundary
['3527000000000000', null], // below 3528
['3590000000000000', null], // above 3589
['30569309025904', 'diners'],
['9792000000000000', 'troy'],
['6212345678901234', 'unionpay'],
['8171999927660000', 'unionpay'],
['5018000000000000', 'maestro'],
['6759000000000000', 'maestro'],
];
TESTS.forEach(([pan, want]) => {
const got = detectBrand(pan);
console.assert(got === want, `${pan}: expected ${want}, got ${got}`);
});
One case in that list is there because it caught a real bug during writing.
6011000000000000000 is a 19-digit Discover number, and against the widely circulated
Discover pattern — which allows only 16 digits — it falls through every specific rule and
is detected as Maestro. The (?:\d{3})? suffix on the Discover pattern above is the
fix. If you copied a Discover regex from anywhere, that is the case to try first.
The numbers used here are synthetic and Luhn-valid where a real card would be; generate more with the card generator, check them against the validator, and pair this with the Luhn implementations — brand detection and checksum validation are separate checks and both belong in your form.
The patterns and test cases above were last checked against provider documentation on . Providers do change what they publish — the official link beside each claim is authoritative.