Credit card number validation in Java takes three checks, namely that the input contains only digits and separators, that the number has a prefix and length that a card network uses, and that the last digit passes the Luhn checksum. A credit card regex handles the first two checks and also tells us the card brand. The Luhn checksum is arithmetic, so it needs a few lines of Java code.
We use these checks in checkout forms to catch typing errors before the payment request, and to show the card logo while the user types. All card numbers shown are the sandbox test numbers that Cybersource, a Visa company, publishes for developers, and they cannot be charged.
The following example runs the three checks on test numbers with the methods of the class CreditCards, which are built step by step in section 2 to section 6.
Optional<String> digits = CreditCards.digitsOnly("4111 1111 1111 1111"); // Optional[4111111111111111]
boolean known = CreditCards.CARD.matcher("4111111111111111").matches(); // true
Optional<String> master = CreditCards.brand("5555555555554444"); // Optional[mastercard]
Optional<String> series2 = CreditCards.brand("2222420000001113"); // Optional[mastercard], 2-series
Optional<String> amex = CreditCards.brand("378282246310005"); // Optional[amex]
boolean luhnOk = CreditCards.passesLuhn("4111111111111111"); // true
boolean luhnBad = CreditCards.passesLuhn("4111111111111112"); // false
boolean valid = CreditCards.isValidCardNumber("4111-1111-1111-1111"); // true
Notice that the brand regex alone cannot reject “4111111111111112”, because only the Luhn check looks at the last digit. We go through the prefixes of each brand, the brand regex with named groups, the input cleanup, the Luhn algorithm and the complete validator. Readers new to character classes and groups can start with our introduction to Java regex.
1. Prefixes and Lengths by Card Brand
A card number (also called the PAN, primary account number) is 8 to 19 digits long. Its first digits, the issuer identification number (IIN), identify the network and the issuing bank, and the last digit is a check digit. The numbering follows the ISO/IEC 7812 standard.
The prefixes in the table come from the IIN table in the Wikipedia article on payment card numbers. Networks add ranges from time to time, so a brand regex needs a review whenever a network announces a new range.
| Brand | Starts with | Length |
|---|---|---|
| Visa | 4 | 13, 16 or 19 |
| Mastercard | 51 to 55, or 2221 to 2720 | 16 |
| American Express | 34 or 37 | 15 |
| Discover | 6011, 644 to 649, or 65 | 16 to 19 |
| Diners Club International | 300 to 305, 36 or 38 | 14 |
| JCB | 3528 to 3589 | 16 to 19 |
The Diners Club row covers the classic 14-digit numbers only. Wikipedia lists more prefixes and lengths for Diners Club, and in the US and Canada, Diners Club cards use Mastercard numbers.
2. A Regex for Each Card Brand
The pattern runs on digits only, after spaces and hyphens are removed as described in section 4. It has one alternative per brand, and each alternative is a named group, written (?<name>…), so that we can later ask which brand matched. The class CreditCards holds every pattern and method of this article, and the example project adds JUnit tests for each range.
final class CreditCards {
static final Pattern CARD = Pattern.compile(
"^(?:(?<visa>4[0-9]{12}(?:[0-9]{3}){0,2})"
+ "|(?<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})"
+ "|(?<amex>3[47][0-9]{13})"
+ "|(?<discover>(?:6011|64[4-9][0-9]|65[0-9]{2})[0-9]{12,15})"
+ "|(?<diners>3(?:0[0-5]|[68][0-9])[0-9]{11})"
+ "|(?<jcb>35(?:2[89]|[3-8][0-9])[0-9]{12,15}))$");
static final Pattern ALLOWED_INPUT = Pattern.compile("^[0-9 -]+$");
static Optional<String> digitsOnly(String input) {
if (input == null || !ALLOWED_INPUT.matcher(input.strip()).matches()) {
return Optional.empty();
}
return Optional.of(input.replaceAll("[ -]", ""));
}
static Optional<String> brand(String digits) {
Matcher m = CARD.matcher(digits);
if (!m.matches()) {
return Optional.empty();
}
return m.namedGroups().keySet().stream()
.filter(name -> m.group(name) != null)
.findFirst();
}
static boolean passesLuhn(String digits) {
int sum = 0;
boolean doubleIt = false;
for (int i = digits.length() - 1; i >= 0; i--) {
int d = digits.charAt(i) - '0';
if (doubleIt) {
d *= 2;
if (d > 9) {
d -= 9;
}
}
sum += d;
doubleIt = !doubleIt;
}
return sum % 10 == 0;
}
static boolean isValidCardNumber(String input) {
return digitsOnly(input)
.filter(d -> CARD.matcher(d).matches())
.filter(CreditCards::passesLuhn)
.isPresent();
}
}
Each alternative of CARD encodes the prefix and the length from the table in section 1.
| Group | Regex | How it reads |
|---|---|---|
| visa | 4[0-9]{12}(?:[0-9]{3}){0,2} | 4, followed by 12 digits and zero, one or two blocks of 3, so 13, 16 or 19 digits |
| 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} | A four-digit prefix from 5100 to 5599 or 2221 to 2720, followed by 12 digits |
| amex | 3[47][0-9]{13} | 34 or 37, followed by 13 digits |
| discover | (?:6011|64[4-9][0-9]|65[0-9]{2})[0-9]{12,15} | 6011, 644x to 649x or 65xx, followed by 12 to 15 digits |
| diners | 3(?:0[0-5]|[68][0-9])[0-9]{11} | 300 to 305, 36x or 38x, followed by 11 digits |
| jcb | 35(?:2[89]|[3-8][0-9])[0-9]{12,15} | 3528 to 3589, followed by 12 to 15 digits |
The Mastercard 2-series range is the hardest part, because a regex cannot compare numbers. A numeric range such as 2221 to 2720 must be split into pieces that each fit a character class, here 2221 to 2229, 2230 to 2299, 2300 to 2699, 2700 to 2719 and 2720.
boolean visa = CreditCards.CARD.matcher("4622943127013705").matches(); // true, Visa
boolean series2 = CreditCards.CARD.matcher("2222630000001125").matches(); // true, Mastercard 2-series
boolean discover = CreditCards.CARD.matcher("6011111111111117").matches(); // true, Discover
boolean jcb = CreditCards.CARD.matcher("3566111111111113").matches(); // true, JCB
boolean edge2720 = CreditCards.CARD.matcher("2720990000000000").matches(); // true, last 2-series prefix
boolean edge2721 = CreditCards.CARD.matcher("2721000000000000").matches(); // false, outside the range
3. Detecting the Card Brand
After a successful match, one named group holds text and the others are null. Since Java 20, Matcher.namedGroups() returns the group names of the pattern, so the method brand() finds the matching brand without listing the names a second time.
Optional<String> visa = CreditCards.brand("4111111111111111"); // Optional[visa]
Optional<String> master = CreditCards.brand("2222420000001113"); // Optional[mastercard]
Optional<String> discover = CreditCards.brand("6011111111111117"); // Optional[discover]
Optional<String> jcb = CreditCards.brand("3566111111111113"); // Optional[jcb]
Optional<String> zeros = CreditCards.brand("0000000000000000"); // Optional.empty
Say a ticket shop accepts Visa and Mastercard only. While the customer types, the shop shows the card logo from brand(), and it rejects an American Express number before the customer fills in the rest of the form. To accept fewer brands in the pattern itself, we delete their alternatives, and we check that no empty alternative is left behind, because a stray | at the end of the group lets the empty string match.
3.1. Two Bugs in a Widely Copied Pattern
Many tutorials and libraries still use an older one-line pattern that predates the Mastercard 2-series. It has two problems today.
- Its Mastercard branch knows only the 51 to 55 prefixes, so it rejects every 2-series card.
- Its Diners Club branch, 3(?:0[0-5]|[68][0-9])?[0-9]{11}, has a ? after the prefix group. That makes the prefix optional, so any 12-digit number that starts with 3 passes.
Pattern oldCard = Pattern.compile("^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}"
+ "|3(?:0[0-5]|[68][0-9])?[0-9]{11}|6(?:011|5[0-9]{2})[0-9]{12}|(?:2131|1800|35[0-9]{3})[0-9]{11})$");
boolean oldSeries2 = oldCard.matcher("2222420000001113").matches(); // false, Mastercard 2-series rejected
boolean newSeries2 = CreditCards.CARD.matcher("2222420000001113").matches(); // true
boolean oldTwelve = oldCard.matcher("312345678901").matches(); // true, 12 digits accepted as Diners
boolean newTwelve = CreditCards.CARD.matcher("312345678901").matches(); // false
The older JCB branch also accepts the 2131 and 1800 prefixes, which the current IIN table no longer lists for JCB, and its Visa and Discover branches do not allow 19-digit numbers.
4. Cleaning the Input
Card numbers are printed in groups, and people type them with spaces or hyphens, such as “4111 1111 1111 1111” or “3782 822463 10005” for an American Express card. The method digitsOnly() first checks that the input contains only digits, spaces and hyphens, and removes the separators after that.
Optional<String> hyphens = CreditCards.digitsOnly("4111-1111-1111-1111"); // Optional[4111111111111111]
Optional<String> amex = CreditCards.digitsOnly(" 3782 822463 10005 "); // Optional[378282246310005]
Optional<String> dots = CreditCards.digitsOnly("4111.1111.1111.1111"); // Optional.empty, dots
Optional<String> letterO = CreditCards.digitsOnly("4111 1111 1111 111O"); // Optional.empty, letter O
Optional<String> missing = CreditCards.digitsOnly(null); // Optional.empty
We reject other characters instead of deleting everything that is not a digit. Removing the letter from “4111 1111 1111 111O” would turn a typo into a different 15-digit number.
5. The Luhn Checksum in Java
The Luhn algorithm, also called the mod 10 algorithm, detects typing errors. A regex cannot check it, because the check digit depends on the sum of all the other digits. The rule works from right to left.
- Keep the rightmost digit (the check digit) as it is.
- Double every second digit, moving left. If the result is greater than 9, subtract 9.
- Add all the digits. The number is valid when the sum is divisible by 10.
For the American Express test number 378282246310005, the doubled digits are in the 2nd, 4th, 6th and later positions from the right.
| Digit | 3 | 7 | 8 | 2 | 8 | 2 | 2 | 4 | 6 | 3 | 1 | 0 | 0 | 0 | 5 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Doubled? | yes | yes | yes | yes | yes | yes | yes | ||||||||
| Value | 3 | 5 | 8 | 4 | 8 | 4 | 2 | 8 | 6 | 6 | 1 | 0 | 0 | 0 | 5 |
The values add up to 60, which is divisible by 10, so the number passes. The 7 became 5 because 7 x 2 = 14 and 14 – 9 = 5. The method passesLuhn() in section 2 implements these steps with a flag that flips on every digit.
boolean amex = CreditCards.passesLuhn("378282246310005"); // true
boolean changed = CreditCards.passesLuhn("378282246310006"); // false, last digit changed
boolean swapped = CreditCards.passesLuhn("372882246310005"); // false, 8 and 2 swapped
boolean zeros = CreditCards.passesLuhn("0000000000000000"); // true, but no brand matches

The Luhn check catches every single wrong digit and most swaps of two neighboring digits. The last line shows why it is not enough on its own, because sixteen zeros pass the checksum and only the brand regex rejects them. ISBNs use a similar weighted checksum, shown in validating ISBN-10 and ISBN-13 in Java.
6. Putting the Checks Together
The method isValidCardNumber() chains the three steps with Optional. It cleans the input, matches a brand pattern and runs the Luhn check.
boolean master = CreditCards.isValidCardNumber("5555-5555-5555-4444"); // true
boolean discover = CreditCards.isValidCardNumber("6011111111111117"); // true
boolean luhn = CreditCards.isValidCardNumber("4111111111111112"); // false, Luhn fails
boolean zeros = CreditCards.isValidCardNumber("0000000000000000"); // false, no brand
boolean tooShort = CreditCards.isValidCardNumber("4111 1111"); // false, too short
A number that passes all three checks is well formed. It can still be expired, blocked or belong to someone else, and only an authorization request through a payment provider answers that. Client-side validation exists to catch typing errors early, before the payment request.
Card numbers are also regulated data. The PCI Security Standards Council sets the rules for storing and displaying them, and in practice most applications pass the number to a payment provider and never store it.
7. Credit Card Regex FAQs
Checkout forms bring up these questions about card numbers.
7.1. Can a Regex Check the Luhn Digit?
No. The check digit depends on the sum of all other digits, and a regex cannot add numbers. We run the regex for prefix and length, and a few lines of Java for the checksum.
7.2. Do Test Card Numbers Pass the Luhn Check?
Yes. Published test numbers such as 4111111111111111 are built to pass the brand and Luhn checks, so they work in sandbox payment flows, but a live payment provider declines them.
7.3. How Do We Mask a Card Number for Display?
We keep the last four digits and replace the rest, which PCI DSS allows for display.
String masked = "4111111111111111".replaceAll("\\d(?=\\d{4})", "*"); // "************1111"
7.4. Should We Validate the CVV and Expiry Date With a Regex Too?
Only the format. A CVV is 3 digits, or 4 for American Express, so ^[0-9]{3,4}$ is enough, and the expiry date is best parsed into a YearMonth and compared with the current month. The payment provider checks whether both values are correct.
7.5. Why Does a 19-Digit Visa Number Fail With Older Patterns?
Older patterns allow only 13 or 16 digits for Visa. Visa issues 19-digit numbers as well, so (?:[0-9]{3}){0,2} in section 2 accepts 13, 16 and 19 digits.
8. Conclusion
Validating a credit card number is three separate checks. A character check and a separator cleanup handle user input, a regex with one named group per brand checks the prefix and length and tells us the brand, and the Luhn algorithm in a few lines of Java catches typing errors.
Prefix ranges change over time, as the Mastercard 2-series showed, so the brand pattern needs an occasional review against the current IIN table. None of these checks proves that a card can be charged, which only the payment provider can tell us.
9. References
- Cybersource Testing Guide, test card numbers
- Payment card number (Wikipedia)
- Luhn algorithm (Wikipedia)
- Matcher.namedGroups() (Java SE 25 API)
- PCI Security Standards Council
Happy Learning !!
What is the difference between () and []