# IBAN Validation JavaScript: Mod 97, Regex and a React Field

> IBAN validation JavaScript that catches typos: mod 97 in TypeScript, why a regex is not enough, a React field that formats as you type and Romanian banks.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 9, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 12 min
- Canonical: https://wingo-ui.com/blog/iban-validation-javascript

## TL;DR

To validate an IBAN in JavaScript, remove the spaces, uppercase it, check the length for its country, move the first four characters to the end, turn each letter into two digits (A is 10, Z is 35) and check that the whole number mod 97 equals 1. Carry the remainder one character at a time, because the number is longer than a JavaScript Number can hold exactly. A passing IBAN is well formed, which does not prove that the account exists.

Your payout settings, supplier form or invoice template asks for a bank account, and people paste IBANs in every shape: lowercase, grouped by four, with dashes, with the non-breaking spaces a PDF adds, sometimes with "IBAN:" in front. Many IBAN validation JavaScript snippets stop at a regex, which accepts a mistyped digit, or call `parseInt`, which rejects valid accounts. This tutorial writes the mod 97 check in TypeScript, wires it into a zod rule and a React field that formats as you type, reads the bank out of Romanian IBANs and displays the result with a copy button. The UI uses [Masked Input](https://wingo-ui.com/components/masked-input) (Pro), [Input](https://wingo-ui.com/components/input) and [Copy Button](https://wingo-ui.com/components/copy-button) (both free) from Wingo UI, the library we build, so we are not neutral. The validator is plain TypeScript you can paste anywhere.

## Why is a regex not enough to validate an IBAN?

A regex checks the shape, and a typo keeps the shape. The usual IBAN validation regex, run after you remove the spaces and uppercase, is this:

```ts
const IBAN_SHAPE = /^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/;
```

Two letters for the country, two check digits, then 11 to 30 letters or digits, because IBANs run from 15 characters (Norway) to a maximum of 34. A country pattern is stricter: Romania's is `^RO\d{2}[A-Z]{4}[A-Z0-9]{16}$`. Both accept `RO49AAAA1B31007593840001`, which is the sample Romanian IBAN from the IBAN registry with its last digit changed.

The two check digits exist to catch exactly that. Any string in the Romanian shape passes the regex, while only about 1 in 97 random ones passes mod 97: in a run of 200,000 random strings, about 1% did. Keep the regex anyway, as the first of three checks, so your message can say "this is not an IBAN" before it says "this IBAN has a typo".

## How does the IBAN mod 97 check work?

Move the first four characters to the end, replace each letter with two digits (A is 10, B is 11, up to Z at 35), read the result as one integer and divide it by 97. A valid IBAN leaves a remainder of 1. The [IBAN article on Wikipedia](https://en.wikipedia.org/wiki/International_Bank_Account_Number) describes these steps from ISO 13616. Whoever issues the IBAN computes the check digits as 98 minus the remainder of the same number with `00` in their place, which is why a valid IBAN lands on 1.

Here is the UK sample IBAN worked by hand:

| Step | Value |
|---|---|
| Remove the spaces | GB29NWBK60161331926819 |
| Move the first four characters to the end | NWBK60161331926819GB29 |
| Letters to numbers (N 23, W 32, B 11, K 20, G 16) | 2332112060161331926819161129 |
| Remainder mod 97 | 1 |

That integer has 28 digits, and a JavaScript `Number` is exact only up to 2^53, which has 16 digits. `Number(digits) % 97` and `parseInt(digits, 10) % 97` both return 5 for this IBAN, so they reject a valid account. `BigInt(digits) % 97n` works. Carrying the remainder one character at a time works too, and it skips the conversion to a digit string.

Wikipedia says the check detects all single substitution errors and all or nearly all swaps of neighboring characters. We tested that. On 2,000 random German IBANs, which are all digits after the country code, every single-digit change and every swap of two neighbors failed the check. Letters weaken it: on 2,000 random Romanian-style IBANs with letters in the account part, about 0.35% of single-character changes and of neighbor swaps still passed, roughly 1 in 300, because a letter becomes two digits. Swap the `1B` in the registry sample to `B1` and both read `111`, so `RO49AAAAB131007593840000` passes.

## How do I validate an IBAN in TypeScript?

To validate IBAN numbers, normalize the input, then run three checks in order: the shape, the length for the country, and mod 97. Returning which check failed lets the form explain the problem instead of saying "invalid":

```ts
// lib/iban.ts
// the 42 IBAN country codes in the SEPA schemes (EPC, August 2026), with their
// lengths from SWIFT's IBAN Registry as ibantools 4.5.4 ships them
const LENGTHS: Record<string, number> = Object.fromEntries(
  (
    "AD24 AL28 AT20 BE16 BG22 CH21 CY28 CZ24 DE22 DK18 EE20 ES24 FI18 FR27 " +
    "GB22 GI23 GR27 HR21 HU28 IE22 IS26 IT27 LI21 LT20 LU20 LV21 MC27 MD24 " +
    "ME22 MK19 MT31 NL18 NO15 PL28 PT25 RO24 RS22 SE24 SI19 SK24 SM27 VA22"
  )
    .split(" ")
    .map((entry) => [entry.slice(0, 2), Number(entry.slice(2))]),
);

export type IbanProblem = "format" | "country" | "length" | "checksum";

export const ibanLength = (country: string) => LENGTHS[country.toUpperCase()];

// " iban: ro49 aaaa-1b31 ..." -> "RO49AAAA1B31..."; \s also covers the non-breaking spaces PDFs paste
export function electronicIban(input: string): string {
  return input.trim().toUpperCase().replace(/^IBAN:?/, "").replace(/[\s.-]/g, "");
}

// the remainder of the rearranged IBAN, one character at a time, so the number never outgrows a float
function mod97(iban: string): number {
  const rearranged = iban.slice(4) + iban.slice(0, 4);
  let remainder = 0;
  for (const char of rearranged) {
    // A is 10 ... Z is 35: a letter adds two digits, so shift by 100
    const value = char >= "A" ? char.charCodeAt(0) - 55 : Number(char);
    remainder = (remainder * (value > 9 ? 100 : 10) + value) % 97;
  }
  return remainder;
}

export function checkIban(input: string): { iban: string; problem: IbanProblem | null } {
  const iban = electronicIban(input);
  if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(iban)) return { iban, problem: "format" };
  const length = ibanLength(iban.slice(0, 2));
  if (!length) return { iban, problem: "country" };
  if (iban.length !== length) return { iban, problem: "length" };
  return { iban, problem: mod97(iban) === 1 ? null : "checksum" };
}

export const isValidIban = (input: string) => checkIban(input).problem === null;

// "RO49AAAA1B31007593840000" -> "RO49 AAAA 1B31 0075 9384 0000"
export const formatIban = (input: string) => electronicIban(input).replace(/(.{4})(?=.)/g, "$1 ");

// check digits for test data: ibanCheckDigits("GB", "NWBK60161331926819") -> "29"
export function ibanCheckDigits(country: string, bban: string): string {
  return String(98 - mod97(`${country}00${bban}`)).padStart(2, "0");
}
```

```ts
checkIban("gb29-nwbk-6016-1331-9268-19"); // { iban: "GB29NWBK60161331926819", problem: null }
checkIban("IBAN: RO49 AAAA 1B31 0075 9384 0001"); // problem: "checksum"
checkIban("US12345678901234567"); // problem: "country"
formatIban("ro49aaaa1b31007593840000"); // "RO49 AAAA 1B31 0075 9384 0000"
ibanCheckDigits("RO", "BTRL0123456789ABCDEF"); // "17", so RO17BTRL0123456789ABCDEF passes
```

Four decisions in that file are worth copying even if you rewrite the rest:

- **Uppercase first.** `"n".charCodeAt(0) - 55` is 55, where `"N"` gives 23, so a lowercase IBAN fails without it.
- **Strip every kind of space.** `\s` in a JavaScript regex also matches the non-breaking and narrow spaces that PDFs and bank apps put between the groups.
- **Reject countries you do not pay.** The table holds the 42 IBAN country codes in the [EPC's August 2026 overview of SEPA scheme participants](https://www.europeanpaymentscouncil.eu/sites/default/files/kb/file/2026-08/EPC196-26%20v1.0%20Overview%20scheme%20adherence%202026-08-07.pdf), Albania, Moldova, Montenegro, North Macedonia and Serbia included, so anything else gets `"country"`, the honest answer for a SEPA payout form. If you accept IBANs from every country, use a library (see the last section).
- **Generate test data.** `ibanCheckDigits` builds a valid IBAN from any country and account part, so your fixtures never hold a real person's account.

## What is the Romanian IBAN format?

A Romanian IBAN has 24 characters: `RO`, two check digits, four letters for the bank, and 16 letters or digits for the branch and the account. The four bank letters are the first four characters of the bank's BIC, its SWIFT code, so `BTRL` is Banca Transilvania (BIC `BTRLRO22`). Wikipedia's [IBAN formats by country](https://en.wikipedia.org/wiki/International_Bank_Account_Number#IBAN_formats_by_country) table writes it as `RO kk bbbb cccc cccc cccc cccc`.

| Positions | Registry sample | Meaning |
|---|---|---|
| 1 to 2 | RO | Country |
| 3 to 4 | 49 | Check digits |
| 5 to 8 | AAAA | Bank, the first four characters of its BIC |
| 9 to 24 | 1B31 0075 9384 0000 | Branch and account, letters allowed |

Two rules follow. A "digits only after the bank code" rule rejects real Romanian accounts, since the account part may hold letters: Banca Transilvania IBANs, for one, carry letters such as `RONCRT` right after the bank code. And the bank is one lookup away:

```ts
// lib/iban.ts (continued)
// the four letters after the check digits are the start of the bank's BIC
const RO_BANKS: Record<string, string> = {
  BTRL: "Banca Transilvania",
  RNCB: "BCR",
  BRDE: "BRD",
  INGB: "ING Bank",
  RZBR: "Raiffeisen Bank",
  BACX: "UniCredit Bank",
  CECE: "CEC Bank",
  TREZ: "State Treasury",
};

export function romanianBank(input: string): string | null {
  const iban = electronicIban(input);
  return iban.startsWith("RO") ? (RO_BANKS[iban.slice(4, 8)] ?? null) : null;
}
```

```ts
romanianBank("RO17 BTRL 0123 4567 89AB CDEF"); // "Banca Transilvania"
romanianBank("GB29 NWBK 6016 1331 9268 19"); // null
```

Show the name as a hint the person can recognize. It proves nothing about the account, and the map is a snapshot of banks that merge: when OTP Bank Romania merged into Banca Transilvania in early 2025, its customers received new BTRL IBANs, and [Banca Transilvania sent employers the new IBANs for salaries paid from March 2025](https://intreb.bancatransilvania.ro/en/cum-voi-incasa-salariul-alocatia-pensia-orice-alt-venit-virat-baza-unei-conventii-banca-transilvania-va-lua-legatura-cu-institutiile-care-vireaza-aceste-sume-si-ii-va-informa-cu-privire-la-noile-iban-uri-mod-centralizat/). An old OTP IBAN still passes mod 97.

## How do I build an IBAN field that formats as you type?

Use a masked field that keeps the raw value apart from what it displays. The `iban` preset of Masked Input uppercases as you type, groups by four, keeps the caret right after the character you typed and cleans separators out of a paste. Once the IBAN is complete it runs mod 97 and pops in a check or an alert icon; the red ring and `aria-invalid` wait until you leave the field. On phones it keeps the text keyboard with `autoCapitalize="characters"`, because a numeric keypad has no letters for the country code.

> Live demo (Masked Input): Type or paste gb29-nwbk-6016-1331-9268-19: the field uppercases it, groups it by four and shows a check. Change the last digit and the check turns into an alert. Try it at [Masked Input](https://wingo-ui.com/components/masked-input) and install it with `npx wingo-ui@latest add masked-input`.

The preset ships lengths for nine countries: Romania, Moldova, Germany, the UK, France, Italy, Spain, Bulgaria and Hungary. For any other country it still groups by four but waits for 34 characters before it checks, so a Dutch IBAN never gets its icon. Pass a mask sized from the same table as your validator. The preset keeps its uppercasing and its keyboard settings:

```ts
// lib/iban-mask.ts
import { ibanLength } from "@/lib/iban";

// "AA99 **** **** ..." sized to the country, so the field knows when the IBAN is complete
export function ibanMask(raw: string): string {
  const length = ibanLength(raw.slice(0, 2)) ?? 34;
  let mask = "AA99";
  for (let i = 4; i < length; i++) mask += (i % 4 === 0 ? " " : "") + "*";
  return mask;
}
```

The zod rule turns each failed check into its own message and hands your database the electronic format, without spaces:

```ts
// lib/iban-schema.ts
import { z } from "zod";
import { checkIban, electronicIban, type IbanProblem } from "@/lib/iban";

const MESSAGES: Record<IbanProblem, string> = {
  format: "Enter the full IBAN, starting with the country code, like GB29 NWBK 6016 1331 9268 19",
  country: "We can only pay out to banks in SEPA countries",
  length: "This IBAN is too short or too long for its country",
  checksum: "This IBAN has a typo: the check digits do not match",
};

export const iban = z
  .string()
  .trim()
  .min(1, { error: "Enter your IBAN", abort: true })
  .check((ctx) => {
    const { problem } = checkIban(ctx.value);
    if (problem) ctx.issues.push({ code: "custom", message: MESSAGES[problem], input: ctx.value });
  })
  .transform(electronicIban);
```

Wire it with React Hook Form's `Controller`, because the input element holds the masked text and `onValueChange` hands you the raw value. Passing `validate={isValidIban}` makes the field's icon and the zod rule agree on every IBAN:

```tsx
"use client";

import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { MaskedInput } from "@/components/ui/masked-input";
import { electronicIban, isValidIban, romanianBank } from "@/lib/iban";
import { ibanMask } from "@/lib/iban-mask";
import { iban } from "@/lib/iban-schema";

const schema = z.object({
  holder: z.string().trim().min(1, { error: "Enter the account holder's name" }),
  iban,
});

type Values = z.output<typeof schema>;

export function PayoutForm({ onSave }: { onSave: (values: Values) => Promise<void> }) {
  const form = useForm<z.input<typeof schema>, unknown, Values>({
    resolver: zodResolver(schema),
    // the first error waits for blur, then the field re-checks on every keystroke
    mode: "onTouched",
    defaultValues: { holder: "", iban: "" },
  });
  const { errors, isSubmitting } = form.formState;

  return (
    <form noValidate onSubmit={form.handleSubmit(onSave)} className="flex flex-col gap-4">
      <Input label="Account holder" autoComplete="name" {...form.register("holder")} error={errors.holder?.message} />
      <Controller
        control={form.control}
        name="iban"
        render={({ field, fieldState }) => (
          <MaskedInput
            preset="iban"
            mask={ibanMask}
            validate={isValidIban}
            label="IBAN"
            placeholder="GB29 NWBK 6016 1331 9268 19"
            ref={field.ref}
            value={field.value}
            onValueChange={(raw) => field.onChange(raw)}
            onBlur={field.onBlur}
            // the mask keeps letters, so a pasted "IBAN:" label would become the country code
            onPaste={(event) => {
              const text = event.clipboardData.getData("text");
              if (!/^\s*iban\b/i.test(text)) return;
              event.preventDefault();
              field.onChange(electronicIban(text));
            }}
            // a romanian IBAN names its bank as soon as the bank code is in
            description={romanianBank(field.value) ?? "Paste it with or without spaces."}
            error={fieldState.error?.message}
          />
        )}
      />
      <Button type="submit" loading={isSubmitting}>
        Save payout details
      </Button>
    </form>
  );
}
```

The description reads "Banca Transilvania" as soon as someone types `RO17BTRL`, a quick way for them to confirm they copied the account they meant. The `onPaste` handler covers the one paste the mask cannot clean up. The field drops every separator but keeps letters, so "IBAN: RO17 BTRL ..." copied from a statement would turn into `IB17 BTRL ...` and fail with the country message. The handler strips the label with `electronicIban` before the mask sees it.

If you would rather not write the rule, the Pro [form-validation](https://wingo-ui.com/components/form-validation) lib has `createValidators().iban()`, with one message for any failure and no country limit.

## How do I show an IBAN so people can copy it?

Display it in groups of four and copy it without spaces. The groups help people read it aloud or compare it with a paper invoice. The clipboard should get the electronic format, because a 34-character IBAN grows to 42 characters with spaces, and a bank form with `maxLength={34}` cuts the end off on paste.

```tsx
import { CopyButton } from "@/components/ui/copy-button";
import { electronicIban, formatIban, romanianBank } from "@/lib/iban";

export function PayoutAccount({ iban }: { iban: string }) {
  const bank = romanianBank(iban);
  return (
    <div className="flex items-center justify-between gap-3 rounded-2xl bg-surface p-4">
      <div className="min-w-0">
        <p className="font-mono text-sm">{formatIban(iban)}</p>
        {bank && <p className="text-xs text-muted-foreground">{bank}</p>}
      </div>
      <CopyButton value={electronicIban(iban)} copyLabel="Copy IBAN" />
    </div>
  );
}
```

Copy Button renders an icon button labeled "Copy IBAN". On desktop its tooltip switches to "Copied"; on a phone the check, a short vibration and a screen reader announcement carry the feedback, and the small size keeps a 44px hit area on touch. The grouped IBAN wraps at its spaces, so it never pushes the row wider than a 360px screen.

## Does a valid IBAN mean the account exists?

No. The check digits prove that the IBAN is well formed. A closed account, an account at a bank that has since merged and the wrong person's account all pass. The account holder's name is what catches the last case. Under the EU Instant Payments Regulation, banks must offer Verification of Payee, which compares the payee's name with the account before a credit transfer and returns a result such as match, close match or no match. The [ECB's overview of the regulation](https://www.ecb.europa.eu/paym/retail/instant_payments/html/instant_payments_regulation.en.html) lists October 9, 2025 for the euro area and July 9, 2027 for the non-euro member states, Romania among them.

That check runs at the payer's bank when the money leaves, long after your form was submitted. So check the digits while people type, and ask for the account holder's name next to the IBAN, as the form above does, so the bank has a name to compare.

## Should I use a library like ibantools instead?

Use one when you accept IBANs from every country. As of October 2026, [ibantools](https://www.npmjs.com/package/ibantools) 4.5.4 (published April 2026, MIT or MPL-2.0) ships the length and account pattern of each registry country, `validateIBAN` returns error codes you can map to messages, and `isValidBIC` checks SWIFT codes. It also runs the national check digits some countries add inside the account part, such as Belgium, Spain and France, which the file above skips. One catch for a SEPA form: its `isSEPACountry` in 4.5.4 still returns false for Albania, Moldova, Montenegro, North Macedonia and Serbia. The [iban](https://www.npmjs.com/package/iban) package, the other common answer, was last published in February 2020. For a SEPA-only form, the file above covers the shape, the length and mod 97 without a dependency, and you can read every line of it.

## Should I copy this code or install it?

Copy it when you need one plain field: `lib/iban.ts`, the zod rule and the free Input cover it. In Wingo UI Pro, Masked Input is the field from the demo, and the [invoicing](https://wingo-ui.com/components/invoicing) lib exports `isValidIban` (lengths for 19 countries, while any other country gets the shape and mod 97), `formatIban`, `getIbanBank` and a `RO_BANKS` map of 19 Romanian bank codes, next to the checks from our [Romanian CUI validation guide](https://wingo-ui.com/blog/romanian-cui-validation-javascript) and our [Romanian CNP validator](https://wingo-ui.com/blog/romanian-cnp-validator-javascript). The plans are on the [pricing page](https://wingo-ui.com/pricing). Install the field and the free copy button with `npx wingo-ui@latest add masked-input copy-button`. For the rest of the form anatomy, read [React form components: accessible fields, zod and mobile](https://wingo-ui.com/blog/react-form-components-guide), or browse more [component guides](https://wingo-ui.com/blog/category/components).

## Components in this post

- [Masked Input](https://wingo-ui.com/components/masked-input) (Pro): A text field that formats itself as you type or paste: card number, expiry, CVC, IBAN, CNP, postal code or a custom mask. Install: `npx wingo-ui@latest add masked-input`
- [Invoicing](https://wingo-ui.com/components/invoicing) (Pro): Invoice math and the ids an invoice carries: VAT per rate the way e-Factura checks it, Romanian VAT rates, CUI, IBAN with its bank, CNP. Install: `npx wingo-ui@latest add invoicing`
- [Copy Button](https://wingo-ui.com/components/copy-button) (Free): Copy an IBAN, a tax ID, a link or an API key with one tap: an icon button with a tooltip, a labelled button, and CopyText for inline values. Install: `npx wingo-ui@latest add copy-button`
- [Input](https://wingo-ui.com/components/input) (Free): The single-line text field: icons and addons inside the box, a clear button, a loading spinner, a counter and floating labels. Install: `npx wingo-ui@latest add input`
- [Form validation](https://wingo-ui.com/components/form-validation) (Pro): Zod rules with one set of messages (English and Romanian), react-hook-form glue and a pretend request, for the forms of blocks. Install: `npx wingo-ui@latest add form-validation`

## FAQ

### How do I validate an IBAN in JavaScript?

Remove spaces and dashes, uppercase the string and check its length for the country. Then move the first four characters to the end, replace each letter with its number (A is 10, Z is 35) and compute the remainder mod 97 one digit at a time; the IBAN is valid when the remainder is 1.

### What is the regex for an IBAN?

After you remove the spaces and uppercase, /^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/ matches the shape of any IBAN, and a country pattern such as ^RO\d{2}[A-Z]{4}[A-Z0-9]{16}$ adds the length. Neither one tests the check digits, so run mod 97 after the regex.

### How many characters is a Romanian IBAN?

24: RO, two check digits, four letters for the bank (the first four characters of its BIC) and 16 letters or digits for the branch and the account, for example RO49 AAAA 1B31 0075 9384 0000.

### Does a valid IBAN mean the bank account exists?

No. The check digits only prove that the IBAN is well formed, so a closed account or the wrong person's account still passes. In the EU, Verification of Payee at the payer's bank compares the payee's name with the account before a transfer, from October 9, 2025 in the euro area and from July 9, 2027 in Romania and the other non-euro member states.

### Why does my IBAN check reject valid IBANs?

Often it is Number or parseInt: the rearranged IBAN has more digits than a JavaScript Number holds exactly, so the remainder comes out wrong. Lowercase letters, non-breaking spaces pasted from a PDF and an IBAN: label in front are the other usual suspects.

### How do I find the bank from a Romanian IBAN?

Read characters 5 to 8. They are the first four characters of the bank's BIC, so BTRL is Banca Transilvania and RNCB is BCR. Keep your map current, because merged banks leave old codes behind.

---

Source: https://wingo-ui.com/blog/iban-validation-javascript
