Back to all posts
Tutorial
8 min read

How to Validate a Credit Card Number

DevToolLab Team

DevToolLab Team

September 29, 2026

How to Validate a Credit Card Number

To validate a credit card number, strip the spaces and dashes, confirm 12 to 19 digits remain, and run the Luhn checksum: double every second digit from the right, subtract 9 from any result over 9, and the total must end in 0. Then match the leading digits and the length to a brand such as Visa or Mastercard.

The catch is that all of this happens offline. A number can pass every check here and still belong to no card that exists, because only the issuing bank, through your payment processor, can say whether a card is real, open and funded. Client-side validation is for catching typos before the round trip, not for deciding whether to take the money.

The worked example through this whole post is Stripe's test Visa, 4242 4242 4242 4242, and the same number with its last digit mistyped as 1.

The Fastest Way

Paste the number into the Credit Card Validator. It runs the Luhn check, detects the brand from the prefix and checks the length for that brand, all in your browser, so the number is never sent to a server. It does not contact a bank or card network, so it cannot tell you whether a card is active. Use it with test numbers, like the Stripe and Braintree ones it lists, not with a real card.

The DevToolLab Credit Card Validator page with a card number field, one-click test numbers for Visa, Mastercard, Amex and Discover, and an empty Card Information panel
The DevToolLab Credit Card Validator page with a card number field, one-click test numbers for Visa, Mastercard, Amex and Discover, and an empty Card Information panel

How the Luhn Check Works

The Luhn algorithm is a mod 10 checksum that Hans Peter Luhn of IBM patented as a "computer for verifying numbers" in US Patent 2,950,048, filed January 6, 1954 and granted August 23, 1960. The last digit of a card number is a check digit chosen so the whole number passes.

Walk it for 4242 4242 4242 4242. Start at the rightmost digit and move left. Keep the first digit (2), double the second (4 becomes 8), keep the third (2), double the fourth (8), and so on. A doubled digit above 9 has 9 subtracted, so a doubled 7 (14) counts as 5. For this number the processed digits are 2 8 2 8 2 8 2 8 2 8 2 8 2 8 2 8, which sum to 80. 80 ends in 0, so the number is valid. Change the last digit to 1 and the sum becomes 79, so the typo is caught.

Luhn was designed for exactly this kind of mistake. I brute-forced it on 2,000 random 16-digit Visa-style numbers, and it rejected all 288,000 single-digit substitutions. It also rejected 88 of the 90 possible swaps of two adjacent different digits. The two it misses are 09 and 90: swapping them leaves the sum unchanged.

It is not a security check. Anyone can compute a valid check digit, which is why generated test numbers pass it.

How to Validate a Card Number in JavaScript

This works in the browser and in Node.js. Keep the number as a string the whole way through (the Common Errors section below shows why).

js
function isValidLuhn(input) {
  const digits = input.replace(/[\s-]/g, "");
  if (!/^\d{12,19}$/.test(digits)) return false;

  let sum = 0;
  let double = false;
  for (let i = digits.length - 1; i >= 0; i--) {
    let d = digits.charCodeAt(i) - 48;
    if (double) {
      d *= 2;
      if (d > 9) d -= 9;
    }
    sum += d;
    double = !double;
  }
  return sum % 10 === 0;
}

console.log(isValidLuhn("4242 4242 4242 4242")); // Stripe's test Visa
console.log(isValidLuhn("4242 4242 4242 4241")); // last digit mistyped
console.log(isValidLuhn("378282246310005"));     // Stripe's test Amex
true
false
true

If you would rather not maintain brand rules yourself, Braintree's card-validator package (MIT, version 10.0.4 published January 28, 2026) wraps Luhn, brand detection and expiry parsing:

js
const valid = require("card-validator");

const result = valid.number("4242 4242 4242 4242");
console.log(result.isValid, result.card.niceType, result.card.code);
console.log(valid.number("4242 4242 4242 4241").isValid);
console.log(valid.expirationDate("12/34").isValid);
true Visa { name: 'CVV', size: 3 }
false
true

How to Validate a Card Number in Python

The same loop, using reversed() so the index counts from the right:

Python
def is_valid_luhn(number: str) -> bool:
    digits = number.replace(" ", "").replace("-", "")
    if not digits.isdigit() or not 12 <= len(digits) <= 19:
        return False
    total = 0
    for i, ch in enumerate(reversed(digits)):
        d = int(ch)
        if i % 2 == 1:
            d *= 2
            if d > 9:
                d -= 9
        total += d
    return total % 10 == 0

print(is_valid_luhn("4242 4242 4242 4242"))
print(is_valid_luhn("4242 4242 4242 4241"))
print(is_valid_luhn("4242-4242-4242-424x"))
True
False
False

How to Validate a Card Number in Java

Java 11 and later can run this single file directly with java Luhn.java; this output is from Java 24.

Java
public class Luhn {
    static boolean isValidLuhn(String input) {
        String digits = input.replaceAll("[\\s-]", "");
        if (!digits.matches("\\d{12,19}")) return false;
        int sum = 0;
        boolean dbl = false;
        for (int i = digits.length() - 1; i >= 0; i--) {
            int d = digits.charAt(i) - '0';
            if (dbl) {
                d *= 2;
                if (d > 9) d -= 9;
            }
            sum += d;
            dbl = !dbl;
        }
        return sum % 10 == 0;
    }

    public static void main(String[] args) {
        System.out.println(isValidLuhn("4242 4242 4242 4242"));
        System.out.println(isValidLuhn("4242 4242 4242 4241"));
    }
}
true
false

How to Detect the Card Brand From Its Prefix

The first digits of a card number identify the network, and each network allows only certain lengths. The ranges below come from Braintree's open-source credit-card-type library, version 10.3.0 published June 30, 2026, which is what card-validator uses under the hood. The test numbers are from Stripe's testing docs, checked September 29, 2026.

BrandStarts withLengthsSecurity codeStripe test number
Visa416, 18, 19CVV, 3 digits4242 4242 4242 4242
Mastercard51-55, 2221-272016CVC, 3 digits5555 5555 5555 4444
American Express34, 3715CID, 4 digits3782 822463 10005
Discover6011, 644-649, 6516, 19CID, 3 digits6011 1111 1111 1117

The Mastercard row is where hand-written regexes usually go wrong. Mastercard added the 2-series range, so a shortcut like /^2[2-7]/ looks right but also matches 2200 to 2220, and Braintree's data assigns 2200 to 2204 to Mir, a different network. It also matches 2721 and up, which are not Mastercard. The pattern below spells the range out. I checked it against credit-card-type on every four-digit prefix from 1000 to 9999, and all four brands agree.

js
const BRANDS = [
  { brand: "Visa", re: /^4/, lengths: [16, 18, 19] },
  { brand: "Mastercard", re: /^(5[1-5]|222[1-9]|22[3-9]\d|2[3-6]\d\d|27[01]\d|2720)/, lengths: [16] },
  { brand: "American Express", re: /^3[47]/, lengths: [15] },
  { brand: "Discover", re: /^(6011|64[4-9]|65)/, lengths: [16, 19] },
];

function detectBrand(input) {
  const digits = input.replace(/[\s-]/g, "");
  const match = BRANDS.find((b) => b.re.test(digits));
  if (!match) return "Unknown";
  return match.lengths.includes(digits.length) ? match.brand : `${match.brand} (wrong length)`;
}

for (const n of ["4242424242424242", "2223003122003222", "378282246310005", "37828224631000", "2721000000000000"]) {
  console.log(n.padEnd(17), detectBrand(n));
}
text
4242424242424242  Visa
2223003122003222  Mastercard
378282246310005   American Express
37828224631000    American Express (wrong length)
2721000000000000  Unknown

A regex can detect the brand, but it cannot run Luhn. Keep the two checks separate and require both.

How to Check the Expiry Date

A card is good through the last day of the month printed on it, so 09/26 still works on September 29, 2026 and stops working on October 1. Compare against the first day of the following month rather than the printed month:

js
function isNotExpired(month, year, now = new Date()) {
  const m = Number(month);
  const y = Number(year) < 100 ? 2000 + Number(year) : Number(year);
  if (!Number.isInteger(m) || m < 1 || m > 12) return false;
  // Cards are valid through the last day of the printed month.
  const firstOfNextMonth = new Date(y, m, 1);
  return now < firstOfNextMonth;
}

const today = new Date(2026, 8, 29); // September 29, 2026
console.log(isNotExpired("09", "26", today)); // 09/26 still good today
console.log(isNotExpired("08", "26", today)); // 08/26 expired
console.log(isNotExpired("12", "34", today)); // Stripe's suggested test date
console.log(isNotExpired("13", "30", today)); // no month 13
true
false
true
false

Test Card Numbers That Pass Validation

Use the numbers your processor publishes for test mode. Stripe's list covers every major brand, and its docs say to "Use a valid future date, such as 12/34" and any three-digit CVC, or four digits for American Express. The same page is blunt about real cards: "The Stripe Services Agreement prohibits testing in live mode using real payment method details."

Stripe's testing docs showing the card number table with Visa 4242 4242 4242 4242, Mastercard, Mastercard 2-series, American Express and Discover test numbers, each with its CVC and date rule
Stripe's testing docs showing the card number table with Visa 4242 4242 4242 4242, Mastercard, Mastercard 2-series, American Express and Discover test numbers, each with its CVC and date rule

Common Errors

"The card number is incorrect. Check the card’s number or use a different card." This is Stripe's incorrect_number decline, and invalid_number reads "The card number is invalid. Check the card details or use a different card." Both come back from the API after the request has gone out. A Luhn check before submit stops most typos from ever reaching that point, which also keeps them out of your decline metrics. Both messages are quoted from Stripe's error code reference, checked September 29, 2026.

"The card has expired. Check the expiry date or use a different card." That is expired_card. If your own form let the date through, check whether it compares against the printed month instead of the end of that month; the example above gets this right.

A valid 19-digit card fails your check. Somewhere the number was parsed as a JavaScript Number. Number("6205500000000000004"), Stripe's 19-digit UnionPay test card, returns 6205500000000000000, because it is larger than Number.MAX_SAFE_INTEGER (9007199254740991). The rounding changes the digits and the checksum fails. Treat card numbers as strings from the input field to the API call.

Every 2-series Mastercard is rejected, or a Mir card is labeled Mastercard. Your prefix table predates the 2221-2720 range or uses the /^2[2-7]/ shortcut. Use the explicit pattern from the brand section.

When Not to Do This

Do not let a raw card number reach your own server to run these checks there. Stripe's integration security guide warns that a business handling card data directly "might be required to meet more than 300 security controls in PCI DSS", and points to hosted fields that send the number straight to Stripe instead. Run the Luhn and brand checks in the browser for fast feedback, collect the number through your processor's hosted fields, and never write a card number to logs or analytics. And never treat a passing Luhn check as a fraud signal: it proves the number was typed consistently, nothing more.

  • Credit Card Validator - run the Luhn check and brand detection from this post on a test number without writing any code.
  • IBAN Validator - the bank-account version of the same idea, using a MOD-97 check digit instead of Luhn's mod 10.
  • Fake Data Generator - produce rows of test customers with card-shaped numbers to exercise your checkout validation.
  • Webhook Signature Verifier - check the HMAC on the payment webhook that arrives after the card is charged.

Related Posts

How to Use curl: 12 Commands for APIs

curl commands for GET, POST, PUT and DELETE, JSON and form data, bearer tokens, redirects and timing, plus fixes for the errors curl actually throws.

By DevToolLab Team•

Local AI Git Commit Message Generator

Build a Python CLI and a prepare-commit-msg hook that turn a staged diff into a Conventional Commits message using a model on your own machine.

By DevToolLab Team•

AI-Powered Webhook Handlers: Smart Event Processing for Developers in 2026

Learn how to build AI-powered webhook handlers that classify events, extract structured data, auto-generate responses, and detect anomalies - with real code examples in Node.js and Python.

By DevToolLab Team•