·9 min read

IBAN validation deep dive — mod-97, country lengths, QR-IBAN, BIC drift

The pure-local mod-97 implementation, the per-country length table, the QR-IBAN range check, and why a valid mod-97 does NOT mean the account exists.

IBAN validation is the entry point. It is the simplest possible thing to get right on a payment surface: the format is fixed, the algorithm is mod-97, no network call required. It is also where 90% of "the payment bounced" production bugs originate, because the next layer up assumes the IBAN is real and routable.

This post covers: the ISO 13616 format, the mod-97 algorithm with a 9-digit chunked implementation that avoids BigInt, the per-country length table, the Swiss QR-IBAN range check, and the BIC-drift situation in modern SEPA.

The format

An IBAN is a fixed-format account identifier defined by ISO 13616. The first two characters are the ISO 3166-1 alpha-2 country code (CH, DE, GB, US — note: the US is NOT in the IBAN registry, US accounts have no IBAN). The next two are check digits. The remainder is the country-specific BBAN (Basic Bank Account Number), with a fixed per-country length: 15 chars total for Norway, 22 for Germany, 27 for France, 34 for Malta and Saint Lucia.

The mod-97 algorithm

The validation algorithm has three steps:

  • 1. Strip whitespace. Uppercase. (Many UIs render IBANs with thin-space separators every 4 chars — strip them.)
  • 2. Move the first 4 characters (country code + check digits) to the end.
  • 3. Replace each letter with its A=10..Z=35 equivalent — so the IBAN becomes a long decimal string.
  • 4. Compute the integer mod 97. If the result is 1, the IBAN is valid.

The integer in step 4 can be up to 34 + 4 = 38 digits long, which is well above the 16-digit safe-integer range in JavaScript. The naive fix is BigInt, but BigInt is slow on the V8 hot path. The practical fix: chunk through 9-digit windows. After each chunk, the partial result fits in 32-bit safe-integer range, and the modulo carries forward cleanly. The pure-local browser implementation at lib/iban.ts uses exactly this pattern.

export function isValidIban(raw: string): boolean {
  const iban = raw.replace(/\s+/g, '').toUpperCase()
  if (iban.length < 15 || iban.length > 34) return false
  if (!/^[A-Z0-9]+$/.test(iban)) return false

  const rearranged = iban.slice(4) + iban.slice(0, 4)
  const digits = rearranged.replace(/[A-Z]/g, (c) =>
    String(c.charCodeAt(0) - 55))

  let remainder = 0
  for (let i = 0; i < digits.length; i += 9) {
    remainder = Number(String(remainder) + digits.slice(i, i + 9)) % 97
  }
  return remainder === 1
}

The Swiss QR-IBAN range

A QR-IBAN is a regular Swiss or Liechtenstein IBAN whose institution identifier — digits 5..9 — lives in the range 30000–31999. The range is reserved by SIX Interbank Clearing for QR-bill-issuance-ready accounts.

The relevance: a payment-part rendered against a QR-IBAN is REQUIRED to carry a QRR (27-digit numeric reference). A payment-part against a regular CH IBAN can use SCOR or unstructured remittance. A QR-bill builder that always picks QRR or always picks SCOR will produce slips that the issuer's bank rejects.

The check is one slice + one range comparison:

export function isQrIban(raw: string): boolean {
  const iban = raw.replace(/\s+/g, '').toUpperCase()
  if (!iban.startsWith('CH') && !iban.startsWith('LI')) return false
  const iid = Number(iban.slice(4, 9))
  return iid >= 30000 && iid <= 31999
}

Why valid-mod-97 does not mean valid-account

The mod-97 check guarantees that the digits are internally consistent. It does NOT guarantee that:

  • The account exists at the named bank.
  • The account is open.
  • The account belongs to the named beneficiary.
  • The account accepts inbound transfers (e.g. savings-only accounts may not).
  • The account is in good standing.

Beneficiary-name verification ("Confirmation of Payee" in the UK, "SurePay" in NL, "VOP" in the SEPA-area) is a separate rail-side service. SEPA SCT is rolling out a VOP overlay on a 2025–2026 timeline; the customer-side implementation is a pre-payment lookup against the beneficiary bank.

BIC drift in SEPA

Pre-2016: SEPA SCT required both IBAN and BIC. Post-2016: SCT runs under the "IBAN-only" rule for domestic euro transfers — the BIC is optional and ignored by the rail. Most SEPA banks today no longer require a BIC on outbound pain.001.

But CBPR+ cross-border, US Fedwire (with the post-2025 ISO 20022 migration), and any non-SEPA international flow still require the BIC. The pragmatic emission strategy: emit the BIC when you have it, omit when the rule pack permits.

The free tier

iso-compliant's /iban-check page on the marketing site is the same algorithm in your browser — pure local, no network, no API key. The agent-tier equivalent is isocompliant.validate_iban via the MCP server. The paid-tier equivalent is POST /v1/iban/validate which adds the cached SIX Bank Master + EBA Clearing lookup.

For a treasury engineer evaluating iso-compliant, the IBAN validator is the lowest-friction entry point — paste an IBAN, see a result, no signup.

← All posts