Credit card validation is a two-step job: a regex confirms the number is structurally valid for a known issuer (Visa starts with 4 and is 13 or 16 digits; MasterCard starts with 51-55 or 2221-2720 and is 16 digits; and so on), and then a Luhn-algorithm check confirms the digits pass the issuer's checksum. Regex alone gets you the first half. Below I walk both, with runnable code in JavaScript, Python, and PHP, engine notes, common mistakes, and a test table you can copy.
The reason regex cannot do the whole job is that the Luhn check involves modular arithmetic on the digits, which regex engines do not perform. So the practical pattern is "regex narrows it down, Luhn finishes the verification".
Quick reference
Brand-detection patterns (anchored, digits only, post-normalisation):
Visa: ^4[0-9]{12}(?:[0-9]{3})?$
MasterCard: ^(?:5[1-5][0-9]{14}|2(?:2(?:2[1-9]|[3-9][0-9])|[3-6][0-9]{2}|7(?:[01][0-9]|20))[0-9]{12})$
Amex: ^3[47][0-9]{13}$
Discover: ^6(?:011|5[0-9]{2})[0-9]{12}$
Diners Club: ^3(?:0[0-5]|[68][0-9])[0-9]{11}$
JCB: ^(?:2131|1800|35\d{3})\d{11}$
Combined "any major card":
^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|6(?:011|5[0-9]{2})[0-9]{12}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\d{3})\d{11})$
The brand-detection patterns
Each issuer has a published Issuer Identification Number (IIN) range. The patterns:
Visa: ^4[0-9]{12}(?:[0-9]{3})?$
MasterCard: ^(?:5[1-5][0-9]{14}|2(?:2(?:2[1-9]|[3-9][0-9])|[3-6][0-9]{2}|7(?:[01][0-9]|20))[0-9]{12})$
Amex: ^3[47][0-9]{13}$
Discover: ^6(?:011|5[0-9]{2})[0-9]{12}$
Diners Club: ^3(?:0[0-5]|[68][0-9])[0-9]{11}$
JCB: ^(?:2131|1800|35\d{3})\d{11}$
These are anchored full-string and assume the input has no spaces or hyphens. If your input might have those, normalise first (see below).
A combined "any major card" pattern:
^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|6(?:011|5[0-9]{2})[0-9]{12}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\d{3})\d{11})$
What this regex confirms:
- The number has the right length for some known issuer (13, 14, 15, 16, or 19 digits depending on brand).
- The leading digits fall in a valid issuer range.
What it does NOT confirm:
- That the checksum (Luhn) passes.
- That the card is currently issued, active, or has a real balance.
The Luhn checksum (regex cannot do this alone)
Luhn is a simple modular check used by every major card brand to catch typos:
- From the rightmost digit (the check digit), move left.
- Double every second digit.
- If doubling produces a number 10 or greater, sum its digits (or equivalently, subtract 9).
- Sum all the digits.
- The sum must be divisible by 10.
So 4532015112830366 is checked as: starting from the right 6, then 6 (double 3), then 8, 0 (double 0 is 0), and so on. The total mod 10 is 0 for valid numbers.
Regex cannot perform this. The engine has no arithmetic. You implement Luhn in code.
Examples in JavaScript, Python, and PHP
JavaScript:
const cardPatterns = {
visa: /^4[0-9]{12}(?:[0-9]{3})?$/,
mastercard: /^(?:5[1-5][0-9]{14}|2(?:2(?:2[1-9]|[3-9][0-9])|[3-6][0-9]{2}|7(?:[01][0-9]|20))[0-9]{12})$/,
amex: /^3[47][0-9]{13}$/,
discover: /^6(?:011|5[0-9]{2})[0-9]{12}$/,
};
function detectBrand(digits) {
for (const [brand, pattern] of Object.entries(cardPatterns)) {
if (pattern.test(digits)) return brand;
}
return null;
}
function passesLuhn(digits) {
let sum = 0;
let alt = false;
for (let i = digits.length - 1; i >= 0; i--) {
let n = parseInt(digits[i], 10);
if (alt) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alt = !alt;
}
return sum % 10 === 0;
}
function validateCard(raw) {
const digits = raw.replace(/\D/g, "");
const brand = detectBrand(digits);
return brand && passesLuhn(digits) ? brand : null;
}
validateCard("4532 0151 1283 0366"); // 'visa'
validateCard("4532 0151 1283 0367"); // null (Luhn fails)Python:
import re
CARD_PATTERNS = {
"visa": re.compile(r"^4[0-9]{12}(?:[0-9]{3})?$"),
"mastercard": re.compile(r"^(?:5[1-5][0-9]{14}|2(?:2(?:2[1-9]|[3-9][0-9])|[3-6][0-9]{2}|7(?:[01][0-9]|20))[0-9]{12})$"),
"amex": re.compile(r"^3[47][0-9]{13}$"),
"discover": re.compile(r"^6(?:011|5[0-9]{2})[0-9]{12}$"),
}
def passes_luhn(digits: str) -> bool:
total = 0
for i, ch in enumerate(reversed(digits)):
n = int(ch)
if i % 2 == 1:
n *= 2
if n > 9:
n -= 9
total += n
return total % 10 == 0
def validate_card(raw: str):
digits = re.sub(r"\D", "", raw)
for brand, pat in CARD_PATTERNS.items():
if pat.match(digits) and passes_luhn(digits):
return brand
return NonePHP:
function passesLuhn(string $digits): bool {
$sum = 0;
$alt = false;
for ($i = strlen($digits) - 1; $i >= 0; $i--) {
$n = (int) $digits[$i];
if ($alt) {
$n *= 2;
if ($n > 9) $n -= 9;
}
$sum += $n;
$alt = !$alt;
}
return $sum % 10 === 0;
}
function validateCard(string $raw): ?string {
$digits = preg_replace('/\D/', '', $raw);
$patterns = [
'visa' => '/^4[0-9]{12}(?:[0-9]{3})?$/',
'mastercard' => '/^(?:5[1-5][0-9]{14}|2(?:2(?:2[1-9]|[3-9][0-9])|[3-6][0-9]{2}|7(?:[01][0-9]|20))[0-9]{12})$/',
'amex' => '/^3[47][0-9]{13}$/',
'discover' => '/^6(?:011|5[0-9]{2})[0-9]{12}$/',
];
foreach ($patterns as $brand => $pattern) {
if (preg_match($pattern, $digits) && passesLuhn($digits)) {
return $brand;
}
}
return null;
}Normalising the input first
Real users type their card number with spaces, hyphens, or both: 4532-0151-1283 0366. The regex expects only digits, so always strip non-digit characters first:
- JavaScript:
raw.replace(/\D/g, "") - Python:
re.sub(r"\D", "", raw) - PHP:
preg_replace('/\D/', '', $raw)
After stripping, you have a pure-digit string ready for both regex and Luhn.
Engine compatibility
The brand patterns use non-capturing groups and bounded quantifiers, which every modern engine supports. Luhn is hand-rolled code, not regex, so the engine notes are about the digit-strip step.
| Engine | Brand patterns | Digit-strip helper |
|---|---|---|
| JavaScript | Works | raw.replace(/\D/g, "") |
Python (re) | Works | re.sub(r"\D", "", raw) |
| PHP (PCRE) | Works | preg_replace('/\D/', '', $raw) |
| Java | Works | raw.replaceAll("\\D", "") |
| .NET | Works | Regex.Replace(raw, @"\D", "") |
| Go (RE2) | Works | regexp.MustCompile(\D).ReplaceAllString(raw, "") |
Rust (regex crate) | Works | Regex::new(r"\D").unwrap().replace_all(&raw, "") |
| Ruby | Works | raw.gsub(/\D/, "") |
POSIX ERE (grep -E) | Works (replace \d with [0-9]) | tr -d '[:space:][:punct:]' |
The Luhn function is a few lines of arithmetic in any language. Pick the one I've shown above for your stack, or paste any standard implementation.
Common mistakes
The bugs I've seen in code review.
Skipping the digit-strip. A pattern that expects only digits fails on 4532-0151-1283 0366. Always normalise: raw.replace(/\D/g, "") or equivalent.
Allowing whitespace in the regex itself. A pattern like ^4[0-9 ]{12,16}$ accepts inputs with spaces but also accepts 4 (all spaces). Normalise first, then run a strict digits-only pattern.
Only checking the brand prefix, not the length. A pattern like ^4 matches 4 (one digit) and 41234567890123456789 (twenty). The full per-brand pattern enforces both prefix and length.
Doubling the wrong digits in Luhn. The rule is "double every second digit, starting from the right (not counting the check digit)". A common bug is doubling from the left instead. The code in this article iterates right-to-left with an alt flag that flips on each step.
Implementing Luhn as sum % 10 === 0 without the "subtract 9" step. When doubling produces 10 or more (e.g., 8 doubles to 16), you must reduce by summing the digits or by subtracting 9. Skipping this gives wrong totals.
Storing the card number after validation. Storing card numbers outside a PCI-DSS-compliant tokenisation system is illegal and uninsurable in most jurisdictions. Use a payment processor (Stripe, Adyen, Braintree). The regex+Luhn check is for client-side typo detection only.
Test cases
| Input | Brand | Luhn | Validate result |
|---|---|---|---|
4532 0151 1283 0366 | Visa | Pass | visa |
4532 0151 1283 0367 | Visa | Fail | null (Luhn fails) |
5454 5454 5454 5454 | MC | Pass | mastercard |
3782 822463 10005 | Amex | Pass | amex |
6011 1111 1111 1117 | Discover | Pass | discover |
1234 5678 9012 3456 | None | None | null (no brand) |
4532 0151 1283 036 | Visa-ish | None | null (wrong length) |
FAQ
See also
- How to Match Numbers with Regex: integer, decimal, signed, and scientific notation for the form fields beside credit card inputs
- How to Validate a Strong Password with Regex: the password field on the same checkout form
- How to Match a Date with Regex: the card expiry field next to the number
- Regex Anchors: why
^and$matter for the brand patterns - Regex Lookaheads and Lookbehinds: composing extra constraints on top of brand detection
- Regex Capturing Groups and Backreferences: grouping the IIN ranges in the MasterCard pattern
- Regex Cheat Sheet: the wider syntax and engine compatibility reference
External reference: the Luhn algorithm Wikipedia article describes the math in detail. For brand-detection ranges, the official card brand IIN registry on Wikipedia is the canonical lookup.





