ICAO 9303 MRZ: how to validate ID cards and passports
TD1, TD2 and TD3 formats, 7-3-1 weighted modulo 10 check digits, a worked example with the official ICAO specimen and JavaScript code to validate an MRZ.
By Constaia team8 min read
The machine readable zone (MRZ) is the two or three lines full of < at the bottom of a passport page or on the back of an ID card. It is designed to be read by machines without errors, which is why it carries check digits: if OCR misreads a character, the maths no longer adds up.
This article covers the formats, the check digit algorithm with a real example from the ICAO document, code to validate an MRZ and, above all, what a valid MRZ does not prove.
The standard: ICAO Doc 9303
The MRZ is defined by ICAO Doc 9303 (8th edition). Part 3 covers what is common to all documents; Parts 4 to 7 cover each format. The essentials:
- Only A–Z, 0–9 and the filler character
<are used, printed in the OCR-B typeface. - Dates are written as YYMMDD: 12 July 1942 becomes
420712. - Names are transliterated without diacritics. For example, the ICAO table allows the Spanish Ñ to be written as
NorNXX.
Three formats
| Format | Lines × characters | Typical use | Doc 9303 part |
|---|---|---|---|
| TD1 | 3 × 30 | ID cards (credit card size), such as the Spanish DNI | Part 5 |
| TD2 | 2 × 36 | Some mid-size official documents | Part 6 |
| TD3 | 2 × 44 | Passports | Part 4 |
TD2 looks like TD3 with less room: a shorter name field and one check digit fewer. In practice, what you will see most in European forms is TD1 (national ID cards) and TD3 (passports).
The check digit: 7-3-1 weights, modulo 10
Part 3 (section 4.9) describes the calculation:
- Each character has a value: digits keep their value,
<is 0 and letters go from A = 10 to Z = 35. - Multiply each value by a repeating weight: 7, 3, 1, 7, 3, 1…
- Add up the products.
- The check digit is the remainder after dividing by 10.
| A | B | C | D | E | F | G | H | I | J | K | L | M |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| N | O | P | Q | R | S | T | U | V | W | X | Y | Z |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
The example in Appendix A to Part 3: the date 27 July 1952 (520727).
| Character | 5 | 2 | 0 | 7 | 2 | 7 |
|---|---|---|---|---|---|---|
| Weight | 7 | 3 | 1 | 7 | 3 | 1 |
| Product | 35 | 6 | 0 | 49 | 6 | 7 |
Sum: 103. Remainder after dividing by 10: 3. In the MRZ it is written 5207273.
Worked example: the ICAO specimen passport (TD3)
Appendix A to Part 4 shows a fictitious passport from the state of "Utopia" (code UTO) issued to Anna Maria Eriksson:
PPUTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<
L898902C36UTO7408122F3404159ZE184226B<<<<<16The first line holds the document type (PP), the issuing state (UTO) and the name: surname, <<, then given names separated by <. The second line carries the data with their check digits:
| Positions | Field | Value | Check |
|---|---|---|---|
| 1–9 | Document number | L898902C3 | 10: 6 |
| 11–13 | Nationality | UTO | — |
| 14–19 | Date of birth | 740812 | 20: 2 |
| 21 | Sex | F | — |
| 22–27 | Date of expiry | 340415 | 28: 9 |
| 29–42 | Personal number / optional data | ZE184226B<<<<< | 43: 1 |
| 44 | Composite check | — | 6 |
Let us check the document number, L898902C3:
| Character | L | 8 | 9 | 8 | 9 | 0 | 2 | C | 3 |
|---|---|---|---|---|---|---|---|---|---|
| Value | 21 | 8 | 9 | 8 | 9 | 0 | 2 | 12 | 3 |
| Weight | 7 | 3 | 1 | 7 | 3 | 1 | 7 | 3 | 1 |
| Product | 147 | 24 | 9 | 56 | 27 | 0 | 14 | 36 | 3 |
Sum: 316 → remainder 6. It matches position 10.
In the same way, the date of birth 740812 sums to 122 (check 2), the expiry 340415 sums to 69 (check 9) and the personal number sums to 401 (check 1).
The composite check digit
The last character protects the whole line. It is computed over positions 1–10, 14–20 and 22–43 of the second line, i.e. the fields with their own check digits, skipping nationality and sex:
L898902C36 + 7408122 + 3404159ZE184226B<<<<<1The weighted sum is 896 → composite check 6, the last character of the line.
If the personal number field is empty (all <), its check digit may be < or 0. Allow for both when validating.
TD1: the ID card format
The specimen in Part 5 is a card for the same holder:
I<UTOD231458907<<<<<<<<<<<<<<<
7408122F1204159UTO<<<<<<<<<<<6
ERIKSSON<<ANNA<MARIA<<<<<<<<<<- Line 1: type (
I<), issuing state (UTO), document number in positions 6–14 (D23145890) and its check digit in 15 (7); positions 16–30 hold optional data. - Line 2: date of birth (1–6) and check (7), sex (8), expiry (9–14) and check (15), nationality (16–18), optional data (19–29) and composite check (30).
- Line 3: the name.
The TD1 composite is computed over positions 6–30 of line 1 and 1–7, 9–15 and 19–29 of line 2. In the specimen it is 6.
One detail: if the document number is longer than 9 characters, position 15 holds a < and the number continues in the optional data area, with its check digit at the end.
JavaScript code
const WEIGHTS = [7, 3, 1];
function charValue(c: string): number {
if (c === "<") return 0;
if (c >= "0" && c <= "9") return c.charCodeAt(0) - 48; // 0–9
if (c >= "A" && c <= "Z") return c.charCodeAt(0) - 55; // A=10 … Z=35
throw new Error(`Invalid MRZ character: ${c}`);
}
export function checkDigit(data: string): string {
let sum = 0;
for (let i = 0; i < data.length; i++) sum += charValue(data[i]) * WEIGHTS[i % 3];
return String(sum % 10);
}
export function validateTd3(line1: string, line2: string) {
if (line1.length !== 44 || line2.length !== 44) throw new Error("TD3: two lines of 44");
const fields: Record<string, [string, string]> = {
document_number: [line2.slice(0, 9), line2[9]],
birth_date: [line2.slice(13, 19), line2[19]],
expiry_date: [line2.slice(21, 27), line2[27]],
personal_number: [line2.slice(28, 42), line2[42]],
composite: [line2.slice(0, 10) + line2.slice(13, 20) + line2.slice(21, 43), line2[43]],
};
return Object.fromEntries(
Object.entries(fields).map(([name, [data, digit]]) => [name, checkDigit(data) === digit]),
);
}
validateTd3(
"PPUTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
"L898902C36UTO7408122F3404159ZE184226B<<<<<16",
);
// { document_number: true, birth_date: true, expiry_date: true,
// personal_number: true, composite: true }If you change the expiry to 340416, both the expiry check and the composite fail. That is how you catch a misread (or a clumsy edit).
What the code above does not cover
- OCR errors:
O/0,I/1,B/8andS/5are often confused. In fields that can only be numeric (dates, check digits) you can correct safely; in the document number, which is alphanumeric, you can try substitutions and keep the one that matches. - The century:
74could be 1974 or 2074. For the date of birth, choose the year that is not in the future; for expiry, the one consistent with the document's validity. - Cropped lines: if a photo cuts off the end of a line, pad with
<only if you know fillers were missing, and flag the result as uncertain.
Consistency between the MRZ and the visual zone
A consistent MRZ is not enough: it has to match what is printed in the visual inspection zone (VIZ). Compare at least:
- document number,
- dates of birth and expiry,
- sex and nationality,
- surname and given names, allowing for transliteration (no diacritics,
<as a space, Ñ asNorNXX) and for long names being truncated.
A correct MRZ with data that differs from the printed data is a clear signal to review the document by hand.
Warning: a checksum does not prove authenticity
The algorithm is public and simple. There are public generators that produce MRZs with every check digit correct. Therefore:
- A valid MRZ only shows that the characters are consistent with each other.
- It does not show that the document exists, that it has not been cancelled or that it belongs to the person presenting it.
- Authenticity is checked by other means (for example physical security features or the document's chip), which are beyond what a photo shows.
How Constaia does it
When you analyse a DNI, NIE or passport, Constaia reads the MRZ with OCR, fixes typical misreads and returns two deterministic checks: mrz_checksums (all check digits) and mrz_matches_visual (the MRZ matches the printed data). If something does not add up, the verdict becomes invalid or review with the reason.
curl https://api.constaia.com/v1/analyze \
-H "Authorization: Bearer ck_test_..." \
-F file=@passport.jpg \
-F 'options={"expect":"passport","checks":{"not_expired":true},"storage":"none"}'With a ck_test_… key and the file passport.jpg you get a simulated response without spending credits. The MRZ validator is part of the open source package @constaia/validators. And once more: Constaia is not biometric KYC, and a correct checksum does not prove a document is authentic. More in Checks.
Summary
- TD1 = 3 × 30 (ID cards), TD2 = 2 × 36, TD3 = 2 × 44 (passports).
- Check digit: values (A = 10 … Z = 35,
<= 0), weights 7-3-1, sum, modulo 10. - Validate each field and the composite, and compare the MRZ with the visual zone.
- A correct checksum does not prove authenticity.
Want to try a specific MRZ? Use the free MRZ reader. To build it into your product, create a free account with 250 credits a month.