Credit card regex: validation patterns for every brand
Regular expressions are the quickest way to recognize a card brand and reject obviously malformed input before a payment request is sent. This guide gives you one tested pattern per brand, a combined pattern, working code in three languages and the mistakes that make checkout forms reject valid cards.
Key takeaways
- Strip spaces and dashes first, then match digits only. Never ask customers to type a number without spaces.
- A regex checks the prefix and the length. It cannot tell whether a number is real; add the Luhn checksum for typos and let the issuer decide the rest.
- Mastercard’s 2221–2720 range, 19-digit Visa and UnionPay numbers and variable-length Maestro cards are where most hand-written patterns fail.
- Use the brand regex for UX (logo, input mask, CVC length) and keep the hard validation permissive: an unknown prefix should still be sent to the gateway.
On this page
Most checkout forms need to answer two quick questions while the customer is still typing: which brand is this? and could this possibly be a card number? Regular expressions answer both in microseconds and without a network call. They power the brand logo that appears after the first few digits, the input mask that groups digits as 4-4-4-4 or 4-6-5, and the security-code field that switches between three and four digits.
The patterns below are tested against tens of thousands of numbers produced by our test card generator for every brand and length, against each other (no pattern claims another brand’s numbers) and against a list of near-miss negatives. They behave identically in JavaScript, Python and PHP.
Normalize the input before you match
People type card numbers the way they see them printed: in groups, with spaces, sometimes with dashes, and occasionally with a stray space at the end from autofill or copy-paste. Your regex should never see any of that. Remove the separators first, then match a string that contains digits only.
Strip separators, not everything
Remove spaces and dashes with [\s-] and then require the result to be all digits. It is tempting to strip every non-digit with \D, but that silently turns 4111-1111-abcd-1111 into a valid-looking number. Stripping only the expected separators means garbage input still fails, so you can show a helpful message instead.
Help the browser help you
Use <input type="text" inputmode="numeric" autocomplete="cc-number">. A type="number" field drops leading zeros, adds spinner arrows and may round long values, so it is the wrong control for a 16- to 19-digit identifier. The autocomplete token lets browsers and password managers fill the number correctly, which also reduces typing errors.
Credit card regex for every brand
Each pattern checks two things: the issuer identification prefix the network uses and the lengths that brand issues. All of them expect digits only, so normalize first.
| Brand | Starts with | Length | Pattern |
|---|---|---|---|
| American Express | 34, 37 | 15 | ^3[47][0-9]{13}$ |
| Diners Club | 300–305, 36, 38–39 | 14–19 | ^3(?:0[0-5]|[689][0-9])[0-9]{11,16}$ |
| JCB | 3528–3589 | 16–19 | ^35(?:2[89]|[3-8][0-9])[0-9]{12,15}$ |
| Discover | 6011, 644–649, 65 | 16–19 | ^(?:6011|64[4-9][0-9]|65[0-9]{2})[0-9]{12,15}$ |
| UnionPay | 62 | 16–19 | ^62[0-9]{14,17}$ |
| Maestro | 5018, 5020, 5038, 5893, 6304, 6759, 6761–6763 | 12–19 | ^(?:5018|5020|5038|5893|6304|6759|676[1-3])[0-9]{8,15}$ |
| Mastercard | 51–55, 2221–2720 | 16 | ^(?:5[1-5][0-9]{2}|222[1-9]|22[3-9][0-9]|2[3-6][0-9]{2}|27[01][0-9]|2720)[0-9]{12}$ |
| Visa | 4 | 13, 16, 19 | ^4[0-9]{12}(?:[0-9]{3}){0,2}$ |
If you only need a yes-or-no “looks like a supported card” check, the patterns combine into one expression. It is long, but every branch is anchored, so it is still fast:
^(?:3[47][0-9]{13}|3(?:0[0-5]|[689][0-9])[0-9]{11,16}|35(?:2[89]|[3-8][0-9])[0-9]{12,15}|(?:6011|64[4-9][0-9]|65[0-9]{2})[0-9]{12,15}|62[0-9]{14,17}|(?:5018|5020|5038|5893|6304|6759|676[1-3])[0-9]{8,15}|(?:5[1-5][0-9]{2}|222[1-9]|22[3-9][0-9]|2[3-6][0-9]{2}|27[01][0-9]|2720)[0-9]{12}|4[0-9]{12}(?:[0-9]{3}){0,2})$A combined pattern cannot tell you which brand matched, so most forms use the individual patterns in a loop and stop at the first hit. The ranges above don’t overlap, so the order does not change the result.
How each pattern is built
Understanding the construction makes it easy to update a pattern when a network adds a range — which happens more often than most codebases expect.
Visa: one prefix, three lengths
Visa numbers start with 4. The pattern takes the 4, twelve more digits (13 in total) and then up to two optional groups of three digits, which gives exactly 13, 16 or 19 digits. Writing [0-9]{12,18} instead would also accept 14, 15, 17 and 18 digits, which Visa does not issue.
Mastercard: two ranges in one expression
The classic range is 51–55. The 2-series range, 2221 to 2720, can’t be written as a single character class, so it is split into pieces that each cover a block: 222[1-9] (2221–2229), 22[3-9][0-9] (2230–2299), 2[3-6][0-9]{2} (2300–2699), 27[01][0-9] (2700–2719) and 2720. All Mastercard credit and debit numbers are 16 digits.
American Express, Diners Club and JCB: the 3-series
Prefix 3 is shared by three brands. Amex is 34 or 37 with exactly 15 digits. JCB is the four-digit range 3528–3589, written as 35(?:2[89]|[3-8][0-9]). Diners Club International uses 300–305, 36, 38 and 39; its classic cards have 14 digits, but newer ones can be up to 19, so the pattern allows 14–19.
Discover, UnionPay and Maestro: the 6-series and friends
Discover uses 6011, 644–649 and 65. UnionPay uses 62, and Maestro a list of four-digit prefixes in the 50, 58 and 6 ranges. These brands issue variable lengths: Discover and UnionPay 16–19 digits, and Maestro anything from 12 to 19, which is why their quantifiers are ranges.
Why regex is only step one
A regex proves that the input has the shape of a card number from a given brand. That is useful, but it is a long way from proving that the card is real. Treat validation as layers, each one catching a different class of problem.
| Layer | Catches | Where |
|---|---|---|
| Normalize + regex | Wrong length, wrong prefix, letters, unsupported brand | Browser and server |
| Luhn checksum | A mistyped digit and most swapped neighboring digits | Browser and server |
| Expiry date | Past dates, impossible months, unrealistic future years | Browser and server |
| Security code length | 3 digits for most brands, 4 for American Express | Browser |
| Authorization | Whether the account exists, is open, has funds and matches the CVV and address | Issuing bank, via your gateway |
The first four layers are about user experience: they let you flag a typo instantly instead of after a round trip to the bank. Only the last layer is about truth. A number from a card generator passes every format check and still fails authorization, because there is no account behind it.
Ready-to-use code
The three implementations below are generated from the same table as the patterns above, so they stay in sync. Each one strips separators, rejects anything that is not 12–19 digits, detects the brand and then runs the Luhn checksum. They return the brand together with a machine-readable error you can map to your own messages.
JavaScript (browser or Node.js)
const CARD_PATTERNS = {
amex: /^3[47][0-9]{13}$/,
diners: /^3(?:0[0-5]|[689][0-9])[0-9]{11,16}$/,
jcb: /^35(?:2[89]|[3-8][0-9])[0-9]{12,15}$/,
discover: /^(?:6011|64[4-9][0-9]|65[0-9]{2})[0-9]{12,15}$/,
unionpay: /^62[0-9]{14,17}$/,
maestro: /^(?:5018|5020|5038|5893|6304|6759|676[1-3])[0-9]{8,15}$/,
mastercard: /^(?:5[1-5][0-9]{2}|222[1-9]|22[3-9][0-9]|2[3-6][0-9]{2}|27[01][0-9]|2720)[0-9]{12}$/,
visa: /^4[0-9]{12}(?:[0-9]{3}){0,2}$/
};
// Luhn checksum over a digits-only string
function luhnValid(digits) {
let sum = 0;
for (let i = digits.length - 1, dbl = false; i >= 0; i--, dbl = !dbl) {
let d = digits.charCodeAt(i) - 48;
if (dbl) { d *= 2; if (d > 9) d -= 9; }
sum += d;
}
return sum % 10 === 0;
}
function detectBrand(input) {
const digits = String(input).replace(/[\s-]/g, ''); // remove spaces and dashes first
for (const [brand, re] of Object.entries(CARD_PATTERNS)) {
if (re.test(digits)) return brand;
}
return null;
}
function validateCardNumber(input) {
const digits = String(input).replace(/[\s-]/g, '');
if (!/^[0-9]{12,19}$/.test(digits)) return { valid: false, brand: null, error: 'format' };
const brand = detectBrand(digits);
if (!brand) return { valid: false, brand: null, error: 'unknown_brand' };
if (!luhnValid(digits)) return { valid: false, brand, error: 'checksum' };
return { valid: true, brand, error: null };
}
console.log(validateCardNumber('4111 1111 1111 1111')); // { valid: true, brand: 'visa', error: null }
console.log(validateCardNumber('4111 1111 1111 1112')); // { valid: false, brand: 'visa', error: 'checksum' }Python 3.10+
import re
CARD_PATTERNS = {
'amex': re.compile(r'^3[47][0-9]{13}$'),
'diners': re.compile(r'^3(?:0[0-5]|[689][0-9])[0-9]{11,16}$'),
'jcb': re.compile(r'^35(?:2[89]|[3-8][0-9])[0-9]{12,15}$'),
'discover': re.compile(r'^(?:6011|64[4-9][0-9]|65[0-9]{2})[0-9]{12,15}$'),
'unionpay': re.compile(r'^62[0-9]{14,17}$'),
'maestro': re.compile(r'^(?:5018|5020|5038|5893|6304|6759|676[1-3])[0-9]{8,15}$'),
'mastercard': re.compile(r'^(?:5[1-5][0-9]{2}|222[1-9]|22[3-9][0-9]|2[3-6][0-9]{2}|27[01][0-9]|2720)[0-9]{12}$'),
'visa': re.compile(r'^4[0-9]{12}(?:[0-9]{3}){0,2}$'),
}
SEPARATORS = re.compile(r'[\s-]')
def luhn_valid(digits: str) -> bool:
total = 0
for i, ch in enumerate(reversed(digits)):
d = int(ch)
if i % 2: # every second digit from the right
d = d * 2 - 9 if d > 4 else d * 2
total += d
return total % 10 == 0
def detect_brand(number: str) -> str | None:
digits = SEPARATORS.sub('', number) # remove spaces and dashes first
for brand, pattern in CARD_PATTERNS.items():
if pattern.fullmatch(digits): # fullmatch: no trailing newline sneaks through
return brand
return None
def validate_card_number(number: str) -> tuple[bool, str | None, str | None]:
digits = SEPARATORS.sub('', number)
if not re.fullmatch(r'[0-9]{12,19}', digits):
return False, None, 'format'
brand = detect_brand(digits)
if brand is None:
return False, None, 'unknown_brand'
if not luhn_valid(digits):
return False, brand, 'checksum'
return True, brand, None
print(validate_card_number('3782 822463 10005')) # (True, 'amex', None)PHP 8
<?php
const CARD_PATTERNS = [
'amex' => '/^3[47][0-9]{13}$/D',
'diners' => '/^3(?:0[0-5]|[689][0-9])[0-9]{11,16}$/D',
'jcb' => '/^35(?:2[89]|[3-8][0-9])[0-9]{12,15}$/D',
'discover' => '/^(?:6011|64[4-9][0-9]|65[0-9]{2})[0-9]{12,15}$/D',
'unionpay' => '/^62[0-9]{14,17}$/D',
'maestro' => '/^(?:5018|5020|5038|5893|6304|6759|676[1-3])[0-9]{8,15}$/D',
'mastercard' => '/^(?:5[1-5][0-9]{2}|222[1-9]|22[3-9][0-9]|2[3-6][0-9]{2}|27[01][0-9]|2720)[0-9]{12}$/D',
'visa' => '/^4[0-9]{12}(?:[0-9]{3}){0,2}$/D',
];
function luhn_valid(string $digits): bool
{
$sum = 0;
$double = false;
for ($i = strlen($digits) - 1; $i >= 0; $i--) {
$d = (int) $digits[$i];
if ($double) {
$d *= 2;
if ($d > 9) $d -= 9;
}
$sum += $d;
$double = !$double;
}
return $sum % 10 === 0;
}
function detect_brand(string $input): ?string
{
$digits = preg_replace('/[\s-]/', '', $input); // remove spaces and dashes first
foreach (CARD_PATTERNS as $brand => $pattern) {
if (preg_match($pattern, $digits) === 1) return $brand; // the D modifier stops $ from matching before a final newline
}
return null;
}
function validate_card_number(string $input): array
{
$digits = preg_replace('/[\s-]/', '', $input);
if (preg_match('/^[0-9]{12,19}$/D', $digits) !== 1) return [false, null, 'format'];
$brand = detect_brand($digits);
if ($brand === null) return [false, null, 'unknown_brand'];
if (!luhn_valid($digits)) return [false, $brand, 'checksum'];
return [true, $brand, null];
}
var_dump(validate_card_number('5555-5555-5555-4444')); // [true, 'mastercard', null]The PHP patterns carry the D modifier and the Python code uses re.fullmatch. Without them, $ in PHP and Python also matches just before a final newline, so "4111111111111111\n" would pass. JavaScript’s $ only matches at the true end of the string unless you add the m flag. Stripping whitespace first closes the gap in all three languages anyway, but it is better not to depend on that.
Pitfalls that reject real customers
A card regex that is too strict costs sales: the customer has a valid card, your form says it is invalid, and they leave. These are the mistakes that show up most often in real checkout code.
Forgetting the Mastercard 2-series
Patterns written before 2016 often use ^5[1-5][0-9]{14}$ for Mastercard. Cards in the 2221–2720 range have been issued for years now, and that old pattern rejects every one of them. Search your codebase for 5[1-5] — front end, back end and any analytics or fraud scripts.
Hard-coding 16 digits
Visa issues 13-, 16- and 19-digit numbers, UnionPay and Discover issue up to 19 digits, and Maestro numbers can be 12 to 19 digits long. A maxlength="16" on the input or a {16} quantifier blocks those customers before your validation even runs. Allow 19 characters of digits (plus room for spaces in the visible input).
Overlapping and co-branded ranges
Card ranges are not perfectly tidy. UnionPay numbers in 622126–622925 are also accepted on the Discover network, and local schemes in many countries issue cards that sit inside or next to the big brands’ ranges. The patterns here deliberately keep 62 as UnionPay; if you route by brand, decide explicitly which brand wins for an overlapping range and document it next to the regex.
Rejecting brands you don’t recognize
Regional schemes such as RuPay, Elo, Mir, Verve or Troy are not in the table above, yet your gateway may accept them. Use the brand regexes for presentation — logo, mask, CVC length — but don’t block submission just because no brand matched. If the number passes the length and Luhn checks, let the payment provider decide; it has the authoritative, up-to-date BIN tables. Our BIN checker shows what a prefix belongs to when you need to investigate.
Validating on every keystroke
Detect the brand as the user types, but show error messages only when the field loses focus or the number reaches a complete length. A form that flashes “invalid card number” after the first digit is technically correct and practically hostile.
Testing your patterns with generated numbers
Regex bugs hide at the edges of ranges, so test both sides of every boundary. A good test suite has three groups:
- Positives for every brand and every length. Generate numbers per brand with our generator or the brand pages such as the UnionPay generator, and type a BIN pattern such as
2720xxxxxxxxxxxxto force a specific prefix. Add your gateway’s documented test card numbers, which include 14-digit Diners and 2-series Mastercard examples. - Boundary negatives. Neighboring prefixes that must not match:
2220and2721(Mastercard),3527and3590(JCB),306(Diners),6012and643(Discover), and lengths one shorter and one longer than allowed. - Junk input. Letters, an empty string, a trailing newline, and numbers with separators that should be stripped before matching.
Run the same cases through every implementation you ship — front end, back end and any mobile app — because a pattern copied between languages can change meaning. Escaping, the behavior of $ and the difference between match, search and fullmatch are the usual culprits. Then paste a few numbers into the card validator to confirm the brand and checksum by eye.
Frequently asked questions
What is the regex for a Visa card number?
^4[0-9]{12}(?:[0-9]{3}){0,2}$ matches Visa numbers of 13, 16 or 19 digits that start with 4. Remove spaces and dashes before matching.
What is the regex for Mastercard including the 2-series?
^(?:5[1-5][0-9]{2}|222[1-9]|22[3-9][0-9]|2[3-6][0-9]{2}|27[01][0-9]|2720)[0-9]{12}$. It covers the classic 51–55 range and the 2221–2720 range (BINs 222100–272099) that Mastercard began issuing from late 2016.
Can a regex validate a credit card number completely?
No. A regex can only check the prefix and the length. Use the Luhn checksum to catch typing errors, and remember that only an authorization with the issuing bank proves that an account exists.
Should I use one combined regex or one per brand?
Use one pattern per brand when you need to know the brand (to show a logo or set the CVC length). A combined pattern is handy for a quick “looks like a card number” check, but it does not tell you which brand matched.
Why does my regex accept a number with a newline at the end?
In Python and PHP, $ also matches just before a final newline. Strip whitespace first, use re.fullmatch in Python and add the D modifier in PHP. JavaScript’s $ does not have this quirk unless you use the m flag.
How do I test my card regex?
Run it against numbers from a test card generator for every brand and length, against your gateway’s documented test cards, and against negatives such as wrong lengths, neighboring prefixes (2220, 2721, 3527, 3590) and letters.
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.