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 Express34 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.

JCB3528 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.

UnionPay62 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.

Maestro50, 5669, and 12 to 19 digits, which is the broadest pattern here in both dimensions. That breadth is exactly why it goes last.

Troy9792 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:

RangePattern pieceWhy
2221–2229222[1-9]Lower boundary
2230–229922[3-9]\d
2300–26992[3-6]\d\dBulk of the range
2700–271927[01]\d
27202720Upper 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:

OverlapNetworksResolution
65Discover, MaestroDiscover first — its ranges are specific
64[4-9]Discover, MaestroSame
622126–622925Discover, UnionPayA historical partnership; a business rule decides
6… broadlyMaestro, Discover, UnionPayMaestro 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 farResultWhy
2nullCould be Mastercard; not enough digits to know
222nullStill could fall below 2221
2223mastercardNow determined
5nullMastercard needs 5[1-5], Maestro needs 5[06-9]
51mastercard
62unionpayDiscover’s overlapping range needs 622
6011discover

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 null as “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.

Frequently Asked Questions

Visa is the easy one: /^4\d{12}(?:\d{3})?(?:\d{3})?$/ matches the 13, 16 and 19-digit lengths Visa permits, all of which begin with 4. The common mistake is /^4\d{15}$/, which accepts only 16 digits and silently rejects both the legacy 13-digit format and the newer 19-digit ranges.
Because it predates 2017. Mastercard exhausted the 51–55 space and began issuing in 2221–2720, an MII originally earmarked for airlines. Any pattern written before that — and most answers still circulating do — classifies a 2-series card as unknown. The range also cannot be written as a simple prefix match, since it is a numeric range and has to be decomposed into five alternatives.
For display, yes — showing the right logo as someone types is exactly what this is for. For business logic, no. Your payment provider returns the brand it actually routed the card as, and that answer is authoritative where yours is a static pattern that ages. Use the regex for the interface and the provider’s response for anything that affects money.
No. Nothing in the digits encodes funding type, country, issuer, or card level — those come from a BIN database, which is commercially compiled data rather than something the number carries. A regex can tell you the network with reasonable confidence and nothing else at all.
Rarely, but the changes matter when they happen: Mastercard’s 2-series in 2017 and the 19-digit ranges are both recent enough that code written a decade ago is wrong today. Review your patterns annually, and log any disagreement between your detection and the brand your provider reports — that mismatch is the earliest signal that a pattern has aged.