To validate an IBAN in JavaScript you need about thirty lines and one piece of care: the MOD-97 step works on a number of up to 66 digits, and JavaScript's Number stops being exact at 16. A validator that converts the string with Number() fails there without any error. The rest is bookkeeping: strip the spaces, check the country, check the length, run the checksum.
This post builds that validator from the bottom up. Every snippet was run with Node.js 20 against the example IBANs from the SWIFT IBAN Registry before it went into the text. At the end there is a section on what the check cannot tell you, and on the iban-check npm package if you would rather not maintain a country table yourself.
What a correct validator checks, in order
- Characters: after removing whitespace, only
A-Zand0-9. - Shape: two letters, two digits, then 11 to 30 alphanumeric characters (the shortest IBAN, Norway, is 15 long; the longest, Saint Lucia, is 32; the standard allows up to 34).
- Country: the first two letters are in the IBAN registry.
USis not. - Length: it matches the fixed length for that country. A German IBAN is 22 characters, every time.
- Check digits: the MOD 97-10 remainder is 1.
- Optionally, the BBAN pattern (digits only in Germany, a letter at position 5 in Italy) and the national check digits some countries add.
Steps 1 to 5 are below. Step 6 is discussed at the end.
Step 1: normalise the input
People paste IBANs from PDFs and bank apps, with spaces, lowercase letters and sometimes non-breaking spaces. Remove all whitespace and uppercase the result. Do not remove anything else: a dash or a dot is a sign of a bad paste and should fail validation, not be silently dropped.
function normaliseIban(input) {
return String(input).replace(/\s+/g, "").toUpperCase();
}
normaliseIban("de89 3704 0044 0532 0130 00"); // "DE89370400440532013000"
\s matches ordinary spaces, tabs, line breaks and the non-breaking space , which covers what copy-and-paste produces.
Step 2: the country length table
Each country has one fixed IBAN length, published in the SWIFT IBAN Registry. The table below is a subset; the registry has 89 entries, and IBAN formats by country lists all of them with their layout.
const IBAN_LENGTHS = {
AT: 20, BE: 16, CH: 21, DE: 22, DK: 18, ES: 24, FI: 18, FR: 27,
GB: 22, IE: 22, IT: 27, LU: 20, NL: 18, NO: 15, PL: 28, PT: 25,
SE: 24, AE: 23, SA: 24, BR: 29,
};
A country missing from the table is an invalid country, not an "unknown length". This is the step that rejects US12345678901234567 and any other string that merely looks like an IBAN.
Step 3: MOD-97 without losing digits
The check digits are computed with the MOD 97-10 algorithm from ISO/IEC 7064, applied as ISO 13616 specifies:
- Move the first four characters to the end:
DE89370400440532013000becomes370400440532013000DE89. - Replace each letter with two digits, A = 10 to Z = 35:
Dis 13,Eis 14, so the string becomes370400440532013000131489. - Interpret the result as a decimal integer and take the remainder modulo 97. A valid IBAN gives 1.
The number in step 3 has 24 digits for Germany and 45 for Malta. Number.MAX_SAFE_INTEGER is 9007199254740991, which has 16. Converting the string with Number() or parseInt() rounds it, and the remainder is garbage:
Number("370400440532013000131489") % 97; // 65, wrong
BigInt("370400440532013000131489") % 97n; // 1n, right
There are two correct ways to do it.
Option A: BigInt
Short and readable. BigInt is available in every current browser and in Node.js.
function mod97BigInt(iban) {
const rearranged = iban.slice(4) + iban.slice(0, 4);
const digits = rearranged.replace(/[A-Z]/g, (ch) => String(ch.charCodeAt(0) - 55));
return Number(BigInt(digits) % 97n);
}
ch.charCodeAt(0) - 55 maps A (code 65) to 10 and Z (code 90) to 35.
Option B: one character at a time
This is how most production libraries do it, because it needs no big-integer support and allocates nothing. The idea is that (a * 10 + b) mod 97 can be computed from a mod 97, so you can fold the digits into a running remainder that stays below 10,000.
function mod97(iban) {
const rearranged = iban.slice(4) + iban.slice(0, 4);
let remainder = 0;
for (const ch of rearranged) {
const value = parseInt(ch, 36); // "0"-"9" -> 0-9, "A"-"Z" -> 10-35
remainder = (value > 9 ? remainder * 100 : remainder * 10) + value;
remainder %= 97;
}
return remainder;
}
A letter contributes two digits, so the remainder is multiplied by 100 for a letter and by 10 for a digit. Both functions return 1 for DE89370400440532013000, GB29NWBK60161331926819 and MT84MALT011000012345MTLCAST001S, which was checked against the BigInt version. IBAN check digits walks through the same arithmetic by hand.
Putting it together
function validateIban(input) {
const iban = normaliseIban(input);
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(iban)) {
return { valid: false, error: "format" };
}
const expected = IBAN_LENGTHS[iban.slice(0, 2)];
if (expected === undefined) return { valid: false, error: "country" };
if (iban.length !== expected) return { valid: false, error: "length" };
if (mod97(iban) !== 1) return { valid: false, error: "checksum" };
return { valid: true, iban };
}
Returning a reason instead of a boolean lets the form say "this IBAN is 2 characters short for Germany" instead of "invalid IBAN".
Results from the test run, with registry examples and deliberate mistakes:
| Input | Result |
|---|---|
DE89 3704 0044 0532 0130 00 |
valid |
de89370400440532013000 |
valid (case and spacing normalised) |
FR1420041010050500013M02606 |
valid (letter inside the BBAN) |
NO9386011117947 |
valid (15 characters) |
DE89370400440532013001 |
checksum (last digit changed) |
DE893704004405320130 |
length (20 instead of 22) |
US12345678901234567 |
country |
DE89-3704-0044-0532-0130-00 |
format (dashes are not whitespace) |
If you want this as a unit test rather than a table, node:assert plus that list is enough:
import assert from "node:assert/strict";
assert.equal(validateIban("DE89 3704 0044 0532 0130 00").valid, true);
assert.equal(validateIban("DE89370400440532013001").error, "checksum");
assert.equal(validateIban("US12345678901234567").error, "country");
Why a regex alone is not enough
A pattern such as /^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/ is a useful first gate and nothing more:
- It accepts
USand every other two-letter code that is not an IBAN country. - It accepts any length between 15 and 34, so a German IBAN missing two digits passes.
- It has no idea about check digits. Swap two neighbouring digits of the German example,
DE89370400440532031000, and the regex is satisfied while MOD-97 returns 31 instead of 1.
Per-country regexes (one pattern per country with the exact BBAN layout) fix the first two points and are a reasonable way to implement step 6 of the list above. They still cannot replace the checksum, and the checksum is where typos are caught: MOD 97-10 detects every substitution of one digit by another (or one letter by another) and every swap of two adjacent digits.
National check digits: valid for MOD-97, still wrong
Twenty-seven countries put check digits of their own inside the BBAN, computed with national algorithms: the French clé RIB, the Italian CIN letter, the Spanish dígitos de control, Belgium's final two digits, and so on. These are independent of the IBAN check digits, so an IBAN can pass MOD-97 and fail the national rule.
Belgium is the easiest to show. The last two digits of a Belgian BBAN are the first ten digits modulo 97 (with 97 standing in for 0):
function belgianCheck(iban) {
const bban = iban.slice(4);
const r = Number(bban.slice(0, 10)) % 97;
return (r === 0 ? 97 : r) === Number(bban.slice(10, 12));
}
belgianCheck("BE68539007547034"); // true (registry example)
belgianCheck("BE16539007547000"); // false, although mod97("BE16539007547000") === 1
BE16539007547000 has correct IBAN check digits and will be accepted by every validator that stops at MOD-97. A Belgian bank would reject it. Whether you implement the national rules depends on the form: for a payout form in one country it is worth it; for a global form, registry data plus MOD-97 is the usual scope. National check digits in the IBAN describes each country's algorithm and its source. The IBAN validator on this site runs both layers in the browser, and shows which one failed:
Using the iban-check library instead
If you would rather not maintain the country table, iban-check is this project's open-source validator for JavaScript and TypeScript. It ships the 89 registry entries, has no runtime dependencies, works in Node.js 18+ and in browsers, and is MIT licensed. The examples below were run against version 0.1.1.
npm install iban-check
import { validateIBAN, isValidIBAN, parseIBAN } from "iban-check";
validateIBAN("DE89 3704 0044 0532 0130 00");
// {
// valid: true,
// iban: "DE89 3704 0044 0532 0130 00",
// normalized: "DE89370400440532013000",
// length: 22,
// country: "DE",
// countryName: "Germany",
// expectedLength: 22,
// checksumValid: true
// }
validateIBAN("US12 3456 7890 1234").error; // "INVALID_COUNTRY"
validateIBAN("DE89370400440532013001").error; // "INVALID_CHECKSUM"
validateIBAN("DE89-3704").error; // "INVALID_CHARACTERS"
isValidIBAN("de89 3704 0044 0532 0130 00"); // true
validateIBAN runs the checks in the order listed at the top of this post and reports the first failure as a machine-readable code (INVALID_CHARACTERS, INVALID_FORMAT, INVALID_COUNTRY, INVALID_LENGTH, INVALID_BBAN_FORMAT, INVALID_CHECKSUM), plus an English message. It also checks the BBAN pattern, so a letter inside a German IBAN is INVALID_BBAN_FORMAT rather than a checksum failure. It does not implement national check digits.
parseIBAN splits the string at the positions the registry defines, which is handy when you need the bank or branch code for a lookup:
parseIBAN("GB29NWBK60161331926819");
// {
// iban: "GB29NWBK60161331926819",
// normalized: "GB29NWBK60161331926819",
// valid: true,
// error: null,
// countryCode: "GB",
// countryName: "United Kingdom",
// checkDigits: "29",
// bban: "NWBK60161331926819",
// length: 22,
// bankIdentifier: "NWBK",
// branchIdentifier: "601613",
// sepa: true
// }
Internally it uses the one-character-at-a-time MOD-97 from Option B, and its test suite compares that against a BigInt reference. The source is on GitHub and the package on npm.
What validation does not tell you
A valid IBAN is a well-formed IBAN. It does not mean the account exists, is open, or belongs to the person on the form. The bank code can be real and the account number random, which is exactly how the generator produces test IBANs that pass every check above without being issued by any bank. Confirming that an account exists needs the receiving bank, a payment, or a Verification of Payee lookup through your payment provider.
For test fixtures, that is a feature: generate a few hundred valid IBANs for the countries you support, feed them through validateIban, and keep a few deliberately broken ones (wrong length, wrong checksum, wrong country) next to them so the error paths are covered too. How to test IBAN validation has a fuller checklist of cases.
Sources
- ISO 13616-1:2020: the IBAN structure, the rearrangement and the letter-to-digit conversion used in the check.
- ISO/IEC 7064: the MOD 97-10 check character system and the errors it detects.
- SWIFT IBAN Registry, Release 102 (June 2026): the per-country lengths and the example IBANs used in the tests.
- MDN, Number.MAX_SAFE_INTEGER and MDN, BigInt: the precision limit of
Numberand theBigInttype. - iban-check on GitHub: the library's README and algorithm notes (version 0.1.1).