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
No. Regex can confirm the number has the right length and starts with a valid issuer prefix, but it cannot verify the Luhn checksum because regex engines do not perform arithmetic. You need code for the Luhn check.
The practical pattern is: regex narrows the input to "valid-looking", then a Luhn function confirms the checksum, then a payment processor confirms the card is real and active.
Because Visa actually issues 13-digit and 16-digit numbers (and rarely 19-digit). The pattern ^4[0-9]{12}(?:[0-9]{3})?$ matches both 13 (12 digits after the 4) and 16 (15 digits after the 4) by making the last 3 optional.
If you only want 16-digit Visa, remove the optional group: ^4[0-9]{15}$.
No. Storing card numbers without a PCI-DSS-compliant tokenisation system is illegal and uninsurable in most jurisdictions. Use a payment processor (Stripe, Adyen, Braintree) that returns a token; store the token, not the number.
The regex and Luhn check are for client-side typo detection only. The actual card verification happens at the processor.
Yes. Every major card brand (Visa, MasterCard, Amex, Discover, JCB, Diners Club) uses the same Luhn algorithm. The brands differ only in IIN ranges and number length, not in the checksum.
UnionPay is the notable exception: most UnionPay cards do not use Luhn, which is why some validators skip the Luhn step for UnionPay-prefix numbers.
The patterns in this article are anchored (^...$) and only allow digits. A number with spaces fails because the spaces are not in the character class.
Strip non-digits before testing: raw.replace(/\D/g, "") in JavaScript, re.sub(r"\D", "", raw) in Python. Match the cleaned string.
The 2-series range (2221-2720) is the newer MasterCard prefix added in 2016. The pattern in this article includes it: 2(?:2(?:2[1-9]|[3-9][0-9])|[3-6][0-9]{2}|7(?:[01][0-9]|20))[0-9]{12}.
If you only use a legacy MC pattern that checks ^5[1-5], those 2-series cards will fail brand detection even though they're valid MasterCard cards.
For typo detection, yes. The regex and Luhn check both run safely in the browser and give the user immediate feedback before they submit. No card data leaves the page unless they hit submit.
For the actual charge, the card data should go straight to the payment processor (Stripe Elements, Braintree Hosted Fields, etc.) which tokenises it client-side. Your server never sees the raw number.
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.





