Credit Card Validation in Node.js — Luhn Algorithm Explained
How the Luhn algorithm works, why a DIY implementation isn't enough for production, and how to detect the card network — all in one API call.
In this guide
1. What is the Luhn algorithm?
The Luhn algorithm (also called Luhn formula or mod-10 algorithm) is a simple checksum formula invented by IBM scientist Hans Peter Luhn in 1954. It was designed to protect against accidental errors — not malicious attacks — when entering numbers by hand.
Today, Luhn validation is used by virtually every credit card network (Visa, Mastercard, Amex, Discover, etc.), as well as IMEI numbers, Canadian Social Insurance Numbers, and more. When a user mistypes a single digit in a card number, Luhn will almost certainly catch it — saving a round-trip to the payment processor.
2. How it works — step by step
Let's walk through the algorithm using the well-known Visa test card number: 4111 1111 1111 1111.
Step 1 — Remove spaces, work right to left
Strip all non-digit characters. Starting from the second-to-last digit and moving left, double every second digit.
| Position (RTL) | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Digit | 4 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
| Doubled | 4 | 2 | 1 | 2 | 1 | 2 | 1 | 2 | 1 | 2 | 1 | 2 | 1 | 2 | 1 | 2 |
Blue = doubled (every 2nd digit from right, starting at position 2)
Step 2 — Subtract 9 from any doubled result > 9
For example, doubling 6 gives 12 → subtract 9 → 3. In this example all doubled digits are 1→2, so no adjustment needed.
Step 3 — Sum all digits
4 + 2 + 1 + 2 + 1 + 2 + 1 + 2 + 1 + 2 + 1 + 2 + 1 + 2 + 1 + 2 = 28... wait, that's the example simplified. Let's use a realistic card:
For 4111111111111111 the sum is 40.
Step 4 — Check divisibility by 10
If sum % 10 === 0 the number is Luhn-valid. For our test card: 40 % 10 = 0 ✓
3. DIY Node.js implementation
The algorithm itself is compact. Here's a correct implementation:
// luhn.js — correct implementation of the Luhn algorithm function luhnCheck(cardNumber) { // Strip spaces, dashes, and other separators const digits = cardNumber.replace(/\D/g, ''); if (digits.length < 13 || digits.length > 19) return false; let sum = 0; let shouldDouble = false; // Traverse right to left for (let i = digits.length - 1; i >= 0; i--) { let digit = parseInt(digits[i], 10); if (shouldDouble) { digit *= 2; if (digit > 9) digit -= 9; } sum += digit; shouldDouble = !shouldDouble; } return sum % 10 === 0; } // Test cards (all should pass) console.log(luhnCheck('4111 1111 1111 1111')); // true — Visa console.log(luhnCheck('5500 0000 0000 0004')); // true — Mastercard console.log(luhnCheck('3714 496353 98431')); // true — Amex console.log(luhnCheck('4111 1111 1111 1112')); // false — one digit off
4. Why Luhn alone isn't enough
The function above works correctly. So why not just use it? Because real-world credit card validation involves several additional layers:
Length validation per network
Valid card numbers are 13–19 digits, but the exact length depends on the network. Visa uses 13 or 16 digits. Amex always has 15. Mastercard is always 16. JCB can be 16–19. A 14-digit number that passes Luhn cannot be a valid Visa card.
Network detection (IIN/BIN ranges)
To show the right card logo in your UI, route to the correct processor, or apply network-specific fee rules, you need to identify the card network. This requires matching the first 1–8 digits (the Issuer Identification Number) against a list of BIN ranges — which is considerably more complex than a single regex.
Security — card numbers must not be logged
Sending a card number as a GET query parameter is a PCI-DSS violation because the full number appears in server access logs, browser history, and proxy logs. Validation must happen over POST with the number in the body, or client-side before the number ever leaves the browser.
Test card numbers
Payment processors publish official test card numbers that pass Luhn but should never be accepted in production. Your server-side validator should know about them if you're doing pre-processing checks outside of a payment SDK.
// These all pass Luhn — they are test cards, not real ones: // 4111 1111 1111 1111 — Visa test // 5500 0000 0000 0004 — Mastercard test // 3714 4963 5398 431 — Amex test // 6011 1111 1111 1117 — Discover test
5. Network detection (BIN ranges)
Each card network owns a set of IIN (Issuer Identification Number) prefixes. The first digits of a card number uniquely identify the network:
| Network | Starts with | Length |
|---|---|---|
| Visa | 4 | 13 or 16 |
| Mastercard | 51–55, 2221–2720 | 16 |
| American Express | 34, 37 | 15 |
| Discover | 6011, 622126–622925, 644–649, 65 | 16 |
| JCB | 3528–3589 | 16–19 |
| Diners Club | 300–305, 36, 38 | 14 |
| UnionPay | 62, 81 | 16–19 |
Maintaining this table correctly is surprisingly tricky — Mastercard expanded its BIN range from the 51–55 prefix to include 2221–2720 in 2017. Any hardcoded regex from before that date will silently reject newer Mastercard numbers.
6. The production-ready solution
The IsValid Credit Card API handles Luhn check, network detection, and length validation in a single POST request. The number is sent in the body — not in the URL — so it never appears in access logs.
Get your free API key at isvalid.dev — 100 calls per day, no credit card required.
Full parameter reference and response schema: Credit Card API docs →
7. Node.js code example
Using the @isvalid-dev/sdk package or the native fetch API (Node 18+). The card number is sent as a JSON body — never as a URL parameter.
// cardValidator.js import { createClient } from '@isvalid-dev/sdk'; const iv = createClient({ apiKey: process.env.ISVALID_API_KEY }); // ── Example usage ──────────────────────────────────────────────────────────── const result = await iv.creditCard('4111 1111 1111 1111'); if (!result.valid) { console.log('Invalid card number'); } else { console.log(`Valid ${result.network} card (${result.length} digits)`); // → "Valid Visa card (16 digits)" }
In an Express checkout handler you might use it like this:
// routes/checkout.js (Express) app.post('/checkout', async (req, res) => { const { cardNumber, ...rest } = req.body; let cardCheck; try { cardCheck = await validateCard(cardNumber); } catch { return res.status(502).json({ error: 'Card validation service unavailable' }); } if (!cardCheck.valid) { return res.status(400).json({ error: 'Invalid card number' }); } // Proceed to payment processor — never store the raw card number const charge = await paymentProcessor.charge({ network: cardCheck.network, // pass tokenised number to your PSP, not the raw one ...rest, }); res.json({ success: true, chargeId: charge.id }); });
8. cURL example
Note the -X POST and Content-Type header — this endpoint only accepts POST to keep the card number out of server logs.
curl -X POST \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"number": "4111111111111111"}' \ "https://api.isvalid.dev/v0/credit-card"
Amex test card (15 digits):
curl -X POST \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"number": "371449635398431"}' \ "https://api.isvalid.dev/v0/credit-card"
Invalid number (fails Luhn):
curl -X POST \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"number": "4111111111111112"}' \ "https://api.isvalid.dev/v0/credit-card"
9. Understanding the response
Valid Visa test card:
{ "valid": true, "network": "Visa", "length": 16, "luhn": true }
Invalid number (bad checksum):
{ "valid": false, "network": "Visa", "length": 16, "luhn": false }
| Field | Type | Description |
|---|---|---|
| valid | boolean | true only when Luhn passes and the length is correct for the detected network |
| network | string | Detected card network: Visa, Mastercard, Amex, Discover, JCB, Diners Club, UnionPay, or Unknown |
| length | number | Number of digits after stripping non-numeric characters |
| luhn | boolean | Whether the number passes the Luhn mod-10 checksum (independently of length) |
Note: valid is the combined result of Luhn + length. luhn lets you distinguish between a bad checksum vs. correct checksum but wrong length for the network.
10. Security and edge cases
Do client-side validation first
Run Luhn validation in the browser before the form is submitted. This gives instant feedback without a network round-trip — and means a raw card number never hits your server at all if it's obviously wrong.
// Client-side pre-check (browser, no API key needed) function luhnCheck(num) { const digits = num.replace(/\D/g, ''); let sum = 0, shouldDouble = false; for (let i = digits.length - 1; i >= 0; i--) { let d = parseInt(digits[i], 10); if (shouldDouble && (d = d * 2 - (d > 4 ? 9 : 0)) > 9) d -= 9; sum += d; shouldDouble = !shouldDouble; } return sum % 10 === 0; } cardInput.addEventListener('blur', () => { if (!luhnCheck(cardInput.value)) { showError('Please check your card number'); } });
Input formatting — accept any separator
Users enter card numbers in many formats: 4111-1111-1111-1111, 4111 1111 1111 1111, 4111111111111111. The API strips all non-digit characters automatically — pass the raw user input.
Show the card logo before submission
Use the network field to render the correct card logo as the user types — you can detect the network from just the first 4–6 digits. Call the API after the user types enough digits to identify the network.
// Show logo after 4+ digits typed cardInput.addEventListener('input', async () => { const digits = cardInput.value.replace(/\D/g, ''); if (digits.length >= 4) { const result = await validateCard(digits); showCardLogo(result.network); // 'Visa', 'Mastercard', etc. } });
Never log or store raw card numbers
PCI-DSS prohibits storing the full PAN (Primary Account Number) unencrypted. Use your payment processor's tokenisation — pass the token, not the number, to your backend. The IsValid API is for format pre-validation only; the actual charge flow must go through a compliant PSP.
Summary
Node.js integration notes
When handling Credit Card Number in a TypeScript codebase, define a branded type to prevent accidental mixing of financial identifier strings at compile time:type CreditCardNumber = string & { readonly __brand: 'CreditCardNumber' }. The IsValid SDK ships with full TypeScript definitions covering all response fields, including country-specific and instrument-specific data, so your editor provides autocomplete on the parsed result without manual type declarations.
In financial data pipelines — payment processors, reconciliation engines, or KYC workflows — Credit Card Number validation sits at the ingestion boundary. Pair the IsValid SDK with decimal.js orbig.js for any monetary amounts tied to the identifier, and usepino for structured logging that attaches the validation result to the transaction reference in every log line, making audit trails straightforward.
Express.js and Fastify middleware
Centralise Credit Card Number validation in a request middleware rather than repeating it in every route handler. The middleware calls the IsValid API, attaches the parsed result toreq.validated, and callsnext() on success. Layer in a Redis cache keyed by the normalised identifier with a 24-hour TTL to avoid redundant API calls for the same value across multiple requests in the same session.
Error handling should distinguish between a 422 response from IsValid (the Credit Card Number is structurally invalid — return this to the caller immediately) and 5xx or network errors (transient failures — retry once after a short delay before surfacing a service-unavailable error). Never swallow validation failures silently; they indicate bad data that could propagate into financial records downstream.
- Assert
process.env.ISVALID_API_KEYis present at server startup, not lazily at first request - Use
Promise.allSettled()for batch validation — it collects all results without aborting on the first failure - Mock the IsValid client with
jest.mock()in unit tests; keep CI pipelines free of real API calls - Store the full parsed API response alongside the raw Credit Card Number in your database — country code, institution data, and check-digit status are useful for downstream logic
When making HTTP calls to the IsValid API directly (without the SDK), the choice between fetch and axios is largely a matter of preference. The native fetch API is available in Node.js 18+ without any additional dependency and is sufficient for simple request/response flows. axios adds automatic JSON parsing, request/response interceptors, and a cleaner timeout API (axios.create({ timeout: 5000 })), which makes it easier to centralise the Authorization header and retry logic in one place. For high-throughput services that make many concurrent API calls, consider undici — the HTTP client underlying Node.js fetch — used directly for its connection pooling and lower overhead.
See also
Validate credit cards instantly
Free tier includes 100 API calls per day. No credit card required. Supports Visa, Mastercard, Amex, Discover, JCB, Diners Club, and UnionPay.