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 (Pro), Input and 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:
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 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":
// 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"); } 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) - 55is 55, where"N"gives 23, so a lowercase IBAN fails without it. - Strip every kind of space.
\sin 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, 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.
ibanCheckDigitsbuilds 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 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:
// 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; } 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. 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. The original article embeds an interactive demo here (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 live on wingo-ui.com or open the Masked Input docs. Install:
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:
// 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:
// 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:
"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 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.
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=Source:https://dev.to/wingo-ui/iban-validation-javascript-mod-97-regex-and-a-react-field-569fRelated posts
I built a Firefox extension that shows live weather + world clocks on every new tab — here's why
A complete claim matrix is not verified evidence: keep both results in Python
What six payment providers keep from a $9 Chrome extension
Debian apt security update: man-in-the-middle arbitrary code execution
