IBAN Validation JavaScript: Mod 97, Regex and a React Field
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:
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 (opens in a new tab) 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:
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":
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 (opens in a new tab), 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 (opens in a new tab) table writes it as RO kk bbbb cccc cccc cccc cccc.
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:
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 (opens in a new tab). 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.
$ npx wingo-ui@latest add masked-inputThe 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:
The zod rule turns each failed check into its own message and hands your database the electronic format, without spaces:
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:
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.
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 (opens in a new tab) 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 (opens in a new tab) 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 (opens in a new tab) 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 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 and our Romanian CNP validator. The plans are on the pricing page. 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, or browse more component guides.
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.
- IBAN
- Form Validation
- TypeScript
- React
- Romania
- Payments