IBAN Validation with Mod 97
How do you catch a mistyped International Bank Account Number before the user submits a direct-debit mandate or payout form — without a bank lookup? Every IBAN carries two check digits computed with the ISO 7064 mod-97-10 algorithm, which detects every single-character error and virtually every transposition. This recipe normalises the input, verifies the country code and its fixed length, runs the mod-97 checksum with either BigInt or a chunked remainder for older engines, formats the result in groups of four for review, and reports failures through setCustomValidity() in the site’s standard Constraint Validation API flow.
When to Use IBAN Checksum Validation
Use this recipe on any form that collects a bank account for SEPA payments, payouts, refunds or salary details in the roughly 80 countries that use IBANs. It is a typo detector, like the card-number Luhn algorithm, but considerably stronger: mod 97 catches all single substitutions, all adjacent transpositions and all but about one in a hundred of any other random error.
- Use it before a payment service provider’s API call, to save a round trip and a confusing bank-side rejection.
- Pair it with a length table so that “DE” numbers of the wrong length get a specific message.
- Do not treat a passing IBAN as a verified account. Account existence and name matching (“confirmation of payee”) need a bank-side service.
In the United States and several other countries accounts are identified by routing and account numbers instead; those use different checks (the ABA routing number has its own weighted checksum) and are out of scope here.
Minimal Working IBAN Validator
// Country code → total IBAN length (subset; extend from the ISO 13616 registry).
const IBAN_LENGTHS: Record<string, number> = {
AD: 24, AT: 20, BE: 16, BG: 22, CH: 21, CY: 28, CZ: 24, DE: 22, DK: 18, EE: 20,
ES: 24, FI: 18, FR: 27, GB: 22, GR: 27, HR: 21, HU: 28, IE: 22, IS: 26, IT: 27,
LI: 21, LT: 20, LU: 20, LV: 21, MC: 27, MT: 31, NL: 18, NO: 15, PL: 28, PT: 25,
RO: 24, SE: 24, SI: 19, SK: 24, SM: 27,
};
export const normaliseIban = (raw: string): string =>
raw.normalize("NFKC").replace(/[\s-]/g, "").toUpperCase();
/** Remainder of a long numeric string mod 97, 7 digits at a time (no BigInt needed). */
function mod97(numeric: string): number {
let remainder = 0;
for (let i = 0; i < numeric.length; i += 7) {
remainder = Number(String(remainder) + numeric.slice(i, i + 7)) % 97;
}
return remainder;
}
export function ibanError(raw: string): string {
const iban = normaliseIban(raw);
if (iban === "") return "Enter your IBAN.";
if (!/^[A-Z]{2}\d{2}[A-Z0-9]+$/.test(iban)) {
return "An IBAN starts with a two-letter country code and two digits, followed by letters and numbers.";
}
const country = iban.slice(0, 2);
const expected = IBAN_LENGTHS[country];
if (expected === undefined) return `IBANs from ${country} are not supported here. Check the country code.`;
if (iban.length !== expected) {
return `IBANs from ${country} have ${expected} characters. Yours has ${iban.length}.`;
}
const rearranged = iban.slice(4) + iban.slice(0, 4);
const numeric = rearranged.replace(/[A-Z]/g, (ch) => String(ch.charCodeAt(0) - 55)); // A=10 … Z=35
if (mod97(numeric) !== 1) return "Check your IBAN — one or more characters look wrong.";
return "";
}
export const formatIban = (raw: string): string => normaliseIban(raw).replace(/(.{4})(?=.)/g, "$1 ");
// Wiring: validate live once the user has left the field, format on blur.
const form = document.querySelector<HTMLFormElement>("#payout")!;
const field = form.querySelector<HTMLInputElement>("#iban")!;
let touched = false;
field.addEventListener("blur", () => {
touched = true;
field.value = formatIban(field.value);
field.setCustomValidity(ibanError(field.value));
field.reportValidity();
});
field.addEventListener("input", () => {
if (touched) field.setCustomValidity(ibanError(field.value));
});
form.addEventListener("submit", (event) => {
field.setCustomValidity(ibanError(field.value));
if (!form.checkValidity()) {
event.preventDefault();
form.reportValidity();
}
});
The chunked mod97 avoids BigInt by carrying the remainder forward: (remainder × 10^7 + next7digits) mod 97 fits comfortably inside a double’s 53-bit integer range because the remainder is at most two digits. Where BigInt is available you can write BigInt(numeric) % 97n === 1n, which is clearer but allocates a large integer per keystroke; the chunked version is faster and works everywhere.
Worked example
For the test IBAN GB82 WEST 1234 5698 7654 32: move GB82 to the end to get WEST12345698765432GB82, replace letters (W=32, E=14, S=28, T=29, G=16, B=11) to get 3214282912345698765432161182, and compute that number modulo 97. The result is 1, so the IBAN is valid. Change any single character and the remainder moves off 1.
IBAN Validator Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
IBAN_LENGTHS |
Record<string, number> |
ISO subset | Country-specific fixed length |
| Normalisation | function | strip spaces/dashes, uppercase | Accept pasted and formatted input |
Chunk size in mod97 |
number |
7 |
Keeps intermediate values under 2^53 |
| Validation timing | events | first on blur, then live | Avoids errors while a 27-character value is typed |
| Display format | groups of 4 | on blur | Matches bank statements for easy comparison |
autocomplete |
token | off or none |
No standard IBAN token exists; do not misuse cc-number |
Verification Steps
import { describe, it, expect } from "vitest";
import { ibanError, formatIban } from "./iban";
describe("ibanError", () => {
it.each([
"GB82 WEST 1234 5698 7654 32",
"DE89 3704 0044 0532 0130 00",
"FR14 2004 1010 0505 0001 3M02 606",
"NL91ABNA0417164300",
])("accepts %s", (iban) => expect(ibanError(iban)).toBe(""));
it("rejects a single changed digit", () => {
expect(ibanError("GB82 WEST 1234 5698 7654 33")).toMatch(/look wrong/);
});
it("reports the expected length", () => {
expect(ibanError("DE89 3704 0044 0532 0130")).toMatch(/22 characters/);
});
it("formats in groups of four", () => {
expect(formatIban("nl91abna0417164300")).toBe("NL91 ABNA 0417 1643 00");
});
});
Edge Cases and Failure Modes
Letter O versus zero. Users read IBANs aloud and from screenshots; O and 0 swap easily in some countries’ IBANs where letters are allowed. The checksum catches it, but the generic message does not say where. If your analytics show frequent failures, add a targeted hint when replacing O with 0 (or vice versa) at one position makes the checksum pass — the same “did you mean” approach used for email domain typo suggestions.
Live formatting while typing. Inserting spaces on every keystroke in a 27-character field fights the caret constantly, and IBANs are usually pasted. Format on blur only, and accept any spacing on input.
Unsupported countries. A hard failure for an unknown country code blocks users with valid IBANs from countries your table missed. If your business can pay those accounts, fall back to the checksum alone when the country is unknown and let the provider decide.
Checksum passes but the bank rejects. A valid-looking IBAN can point to a closed account or a bank that does not accept direct debits. Map the provider’s rejection back onto the field as described in mapping server field errors to form inputs, and keep the message distinct from the typo message so users know to contact their bank rather than retype.
Why Mod 97 Is Stronger Than Luhn
Luhn works in base 10 and was designed for cheap arithmetic with mechanical devices, so it has blind spots — the 09↔90 swap is the famous one. Mod 97 uses a prime modulus larger than the alphabet of possible characters, and the rearranged number includes the check digits themselves, so any change to a single character changes the remainder by an amount that cannot be a multiple of 97. For transpositions, swapping adjacent characters changes the value by a multiple of 9 times a power of ten (or the letter-value equivalent), and none of those is divisible by 97. The result is a checksum that catches every single-character error and every adjacent transposition, with an overall undetected-error rate around 1% for completely random corruption. That is why the error message can confidently say “one or more characters look wrong” rather than hedging about whether the IBAN might still be right.
Frequently Asked Questions
Does a valid IBAN checksum mean the bank account exists?
No. The checksum only proves the IBAN was typed correctly. Whether the account exists, is open and matches the holder's name needs a bank-side check such as confirmation of payee.
Do I need BigInt to validate IBANs in JavaScript?
No. Process the numeric string in chunks of seven digits, carrying the remainder forward. Each step stays well within the safe integer range, and it runs in older engines too.
Should IBAN input be formatted with spaces while typing?
Format on blur instead. IBANs are long and usually pasted, and live reformatting fights the caret. Accept any spacing on input and group into fours once the user leaves the field.
What IBAN lengths should I allow?
Each country has one fixed length, from 15 for Norway to over 30 for a few countries. Check the length for the given country code, and report the expected count in the error.
Related Guides
- Payment Card Validation — the card-side counterpart to bank account checks.
- Luhn Algorithm Credit Card Validation — the simpler checksum used on card numbers.
- Validating on Blur Versus on Input — the timing model used for this long field.
- Transforming and Coercing Form Input with Zod — normalising IBANs inside a schema.