Validate an IBAN in JavaScript (MOD-97 Without BigInt)
A copy-paste validator for browser or Node that gets the two things right that most snippets get wrong: the country-specific length check, and a MOD-97 that does not overflow JavaScript's Number type.
What the IBAN checksum actually verifies
An IBAN (ISO 13616) starts with a two-letter country code and two check digits, followed by the domestic account details. The two check digits are computed so that, after a rearrangement and letter-to-digit conversion, the whole IBAN taken as one huge integer leaves a remainder of exactly 1 when divided by 97. If a single character is mistyped or two characters are swapped, the remainder changes and validation fails. The full math, with a hand-worked example, is in our guide on how check digits work; this page is only about implementing it correctly in JavaScript. If you just need to check one IBAN right now, the IBAN checker runs this exact class of algorithm client-side — nothing you paste there leaves your browser.
The complete validator
This is the whole thing — no dependencies, works in any browser and in Node. Every line is explained below.
const IBAN_LENGTHS = {
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
};
function validateIBAN(input) {
// 1. Clean: uppercase, strip spaces and separators
const iban = String(input || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
// 2. Structural checks
if (iban.length < 15 || iban.length > 34) {
return { valid: false, reason: 'wrong_length' };
}
if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/.test(iban)) {
return { valid: false, reason: 'bad_format' };
}
// 3. Country-specific length
const country = iban.slice(0, 2);
const expected = IBAN_LENGTHS[country];
if (expected && iban.length !== expected) {
return { valid: false, reason: 'wrong_length_for_country',
country, expected, got: iban.length };
}
// 4. Rearrange: move the first 4 chars to the end
const rearranged = iban.slice(4) + iban.slice(0, 4);
// 5. MOD-97 with a running remainder (no big-integer needed)
let remainder = 0;
for (const ch of rearranged) {
// A=10, B=11 ... Z=35; digits stay as they are
const value = ch >= 'A' ? String(ch.charCodeAt(0) - 55) : ch;
for (const digit of value) {
remainder = (remainder * 10 + (digit.charCodeAt(0) - 48)) % 97;
}
}
return { valid: remainder === 1, iban, country };
}
Step by step:
- Cleaning strips spaces, dashes and anything else people paste, and uppercases the rest. Users type IBANs in groups of four (
DE89 3704 ...) — reject that and your form will infuriate everyone. - Structural checks enforce the ISO envelope: 15–34 characters, two letters then two digits.
- Country length catches truncation instantly. A German IBAN is always 22 characters, an Italian one 27, a Norwegian one 15. The table above covers SEPA and nearby countries; unknown country codes fall through to the checksum alone, so the function still gives a useful (if weaker) answer.
- Rearrangement: the first four characters move to the end, per ISO 7064.
- The modulo — the part everyone gets wrong, so it gets its own section.
Why the naive parseInt version silently fails
The textbook description says: convert letters to numbers (A=10 ... Z=35), then take the resulting integer mod 97. The trap is the word integer. For DE89370400440532013000, after rearranging and expanding D→13 and E→14, the numeric string is 370400440532013000131489 — 24 digits. A British IBAN expands to 28 digits; the worst cases run past 30. JavaScript's Number is an IEEE-754 double, exact only up to Number.MAX_SAFE_INTEGER = 9,007,199,254,740,991 — about 16 digits.
So parseInt('370400440532013000131489') does not error. It quietly returns the float 3.70400440532013e+23, and the modulo is computed on that rounded value:
parseInt('370400440532013000131489') % 97 // 65 — wrong
BigInt('370400440532013000131489') % 97n // 1n — correct
The naive version rejects this perfectly valid IBAN, and because the rounding error is arbitrary, some invalid IBANs will land on 1 and pass. It "works" in quick tests with short fake inputs and fails in production — the classic bug in IBAN snippets copied from old forum answers.
The fix in the validator above is a chunked (streaming) modulo, using the identity (a·10 + d) mod 97 = ((a mod 97)·10 + d) mod 97. We feed the number in one digit at a time and reduce mod 97 at every step, so the intermediate value never exceeds 96 × 10 + 9 = 969. No BigInt, no precision loss, runs in every JavaScript engine ever shipped. (You will also see versions that bite off nine digits at a time and prepend the running remainder — same idea, bigger bites; both are exact.) On modern runtimes BigInt is a fine alternative, but the chunked loop costs nothing and never surprises you in an older WebView.
Worked example: DE89 3704 0044 0532 0130 00
This is the standard German test vector. What actually happens inside the function:
- Clean →
DE89370400440532013000(22 chars; DE expects 22 ✓) - Rearrange →
370400440532013000DE89 - Expand letters →
370400440532013000131489 - Stream mod 97 → remainder 1 → valid
And at the console, real output from the exact code above:
validateIBAN('DE89 3704 0044 0532 0130 00')
// → { valid: true, iban: 'DE89370400440532013000', country: 'DE' }
validateIBAN('DE89 3704 0044 0532 0130 01') // one digit changed
// → { valid: false, iban: 'DE89370400440532013001', country: 'DE' }
validateIBAN('DE89 3704 0044 0532 0130') // truncated to 20 chars
// → { valid: false, reason: 'wrong_length_for_country',
// country: 'DE', expected: 22, got: 20 }
A second vector to test letters inside the account part: GB82 WEST 1234 5698 7654 32 expands to the 28-digit string 3214282912345698765432161182 and validates as true; change WEST to TEST and it fails.
Edge cases and classic mistakes
- Leading zeros are load-bearing. The account portion of an IBAN is zero-padded (
...0044 0532 0130 00). If an IBAN passes through a spreadsheet cell or database column typed as a number, those zeros evaporate and the checksum fails on data that used to be correct. Store IBANs as strings, always — the same disease that mangles barcodes, covered in our Excel leading-zeros guide. - Validate after cleaning, store cleaned. Compare and store the compact uppercase form; re-insert display spaces only when rendering.
- Don't regex-only. A pattern like
/^[A-Z]{2}\d{2}[A-Z0-9]+$/accepts any well-shaped garbage. The checksum is the whole point. - Don't skip the length table. MOD-97 alone can miss certain gross errors (like whole missing blocks that happen to land on remainder 1); length-per-country kills those cheaply and gives users a far better error message than "invalid".
- Case: lowercase input is common from mobile keyboards;
toUpperCase()before anything else.
Wiring it into a form is one listener: input.addEventListener('blur', () => { const r = validateIBAN(input.value); input.setCustomValidity(r.valid ? '' : 'Invalid IBAN'); }); — run it on blur rather than every keystroke, or the field yells at users mid-typing.
Checksum vs. registry: what this does not prove
A remainder of 1 means the string is self-consistent — nothing more. It does not prove the account is open, that the bank branch exists, or that the IBAN belongs to the person you intend to pay. Invoice-fraud IBANs validate perfectly. Treat the checksum as the free first gate (see our methodology for what each of our checks does and does not claim), and confirm payee identity through your bank before large transfers.
Validating at scale?
Client-side is right for one form field. For batch jobs — onboarding files, supplier masters, payment runs — the CodeClassify API validates up to 100 IBANs per call with the same length + MOD-97 logic:
curl -X POST "https://codeclassify-api.rosariovitale0096.workers.dev/v1/iban/validate" \
-H "X-Api-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"ibans": ["DE89370400440532013000", "GB82WEST12345698765432"]}'
// → {"ok":true,"count":2,"valid":2,"results":[
// {"input":"DE89370400440532013000","iban":"DE89370400440532013000",
// "country":"DE","valid":true}, ... ]}
The free tier is 10 calls/month, no card required — get a key on the API page. For whole-file workflows, see bulk-validating a CSV via the API.
FAQ
Why does parseInt give the wrong MOD-97 result for an IBAN?
After rearranging and converting letters to digits, an IBAN becomes a 24- to 36-digit number, but JavaScript's Number type is only exact up to 9,007,199,254,740,991 (about 16 digits). parseInt silently rounds anything longer to the nearest representable float, so the % 97 result is computed on a corrupted value. For DE89370400440532013000 the naive approach returns 65 instead of the correct remainder 1, so a perfectly valid IBAN is rejected — and some invalid ones can slip through.
Can I just use BigInt instead of the chunked modulo?
Yes — BigInt(numericString) % 97n gives the exact remainder on Node and every current browser. The chunked (digit-by-digit) modulo does the same thing with plain numbers, so it also runs in older browsers, embedded WebViews, and environments where BigInt is unavailable, and it avoids building the long intermediate string. Both approaches produce identical results; pick one and keep the country length check either way.
Does a valid MOD-97 checksum mean the bank account exists?
No. A remainder of 1 only proves the IBAN is internally consistent — it was not mistyped in a way the checksum can catch. It does not prove the account is open, that the bank code exists, or that the name you have matches the account holder. Checksum validation is the cheap first gate; confirming payee details with the bank or an account-verification service is a separate step.
Check any IBAN right now
Paste an IBAN into the IBAN checker for an instant length + MOD-97 verdict — it runs entirely in your browser, so the number never leaves your machine.
This guide is for general information only. IBAN checksum validation confirms internal consistency of a number, not that an account is open, registered, or owned by any particular party. Always confirm bank details directly with the account holder or bank before sending payments.