<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[doc.cheap]]></title><description><![CDATA[Guides on reading passports and ID cards from code, from doc.cheap. Recognise passports and ID cards with one HTTP call. Free – 100 documents every month, then ]]></description><link>https://doccheap.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ab4aa49a61f2c59c8fc4484/df1bb683-6534-46f1-8c0c-dfbfa7b449c6.png</url><title>doc.cheap</title><link>https://doccheap.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 11:25:51 GMT</lastBuildDate><atom:link href="https://doccheap.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Read, return, forget: processing passport scans without storing the image]]></title><description><![CDATA[What the GDPR asks of a passport image, where copies leak in a typical upload flow, and a read, return, forget pattern that keeps none on the API side.]]></description><link>https://doccheap.hashnode.dev/read-return-forget-processing-passport-scans-without-storing-the-image</link><guid isPermaLink="true">https://doccheap.hashnode.dev/read-return-forget-processing-passport-scans-without-storing-the-image</guid><category><![CDATA[privacy]]></category><category><![CDATA[Security]]></category><category><![CDATA[#gdpr]]></category><category><![CDATA[backend]]></category><category><![CDATA[architecture]]></category><dc:creator><![CDATA[Teoh Chin Heng]]></dc:creator><pubDate>Thu, 29 Oct 2026 01:15:30 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published at <a href="https://doc.cheap/blog/process-passport-scans-without-storing-images">Read, return, forget: processing passport scans without storing the image</a></em></p>
<p>A passport photo is the most sensitive file most apps ever receive. It carries a face, a full name, a date of birth and a document number, and it is enough to open an account somewhere else. Yet in many upload flows the picture is copied five or six times before anyone asks whether it needs to exist at all.</p>
<p>This post looks at the question from the engineering side: what the GDPR says about keeping data, the places where a passport image quietly piles up, and a pattern called read, return, forget – the image is read, the result is returned, and nothing of the picture is kept. It ends with the part that stays your job, because some businesses are required to keep a copy, and from July 2027 a new EU law spells that out.</p>
<h2>What the GDPR actually asks</h2>
<p>The GDPR does not say "never store a passport image". It says something more useful: keep what you need, for as long as you need it, and no longer. Art. 5(1) of <a href="https://eur-lex.europa.eu/eli/reg/2016/679/oj/eng">Reg. (EU) 2016/679</a> sets the principles. Two of them decide most of the design.</p>
<table>
<thead>
<tr>
<th>Principle</th>
<th>Text of Art. 5(1)</th>
<th>What it means for an image</th>
</tr>
</thead>
<tbody><tr>
<td>Data minimisation, point (c)</td>
<td>"adequate, relevant and limited to what is necessary in relation to the purposes for which they are processed"</td>
<td>If your process needs the name, date of birth and document number, the picture itself may not be necessary once they are read.</td>
</tr>
<tr>
<td>Storage limitation, point (e)</td>
<td>"kept in a form which permits identification of data subjects for no longer than is necessary for the purposes for which the personal data are processed"</td>
<td>Every copy needs an end date, and "we never got round to deleting it" is not one.</td>
</tr>
</tbody></table>
<p>Art. 5(2) adds accountability: you must be able to show that you follow these principles. And Art. 25(1), data protection by design, asks for "appropriate technical and organisational measures, such as pseudonymisation, which are designed to implement data-protection principles, such as data minimisation, in an effective manner".</p>
<p>Read together, they turn a legal question into an engineering one. The fewer copies of an image exist, the fewer places you have to describe, secure, back up and eventually empty. A copy that was never written is the only one that needs none of that work.</p>
<h2>Where passport images end up</h2>
<p>Most teams store the image on purpose in one place. The trouble is the places nobody chose. Walk through this list against your own flow.</p>
<table>
<thead>
<tr>
<th>Place</th>
<th>How the image gets there</th>
</tr>
</thead>
<tbody><tr>
<td>Upload bucket</td>
<td>The client uploads to object storage first, and the backend reads it from there. The object outlives the request.</td>
</tr>
<tr>
<td>Request logs</td>
<td>A logging middleware writes request bodies, and a base64 image is a request body.</td>
</tr>
<tr>
<td>Error reports</td>
<td>An exception tracker attaches the payload of the request that failed.</td>
</tr>
<tr>
<td>Queues and retries</td>
<td>A job message carries the image, and a dead-letter queue keeps the failed ones for weeks.</td>
</tr>
<tr>
<td>Backups and snapshots</td>
<td>A database or disk snapshot taken that day holds every image written before it, long after the row is deleted.</td>
</tr>
<tr>
<td>Support tickets</td>
<td>A user mails the photo again "because the upload did not work".</td>
</tr>
<tr>
<td>Analytics and session replay</td>
<td>A tool records the page, including the preview of the selected file.</td>
</tr>
<tr>
<td>The OCR provider</td>
<td>The service that reads the document keeps its own copy, under its own retention rules.</td>
</tr>
</tbody></table>
<p>The last row is the one you control least. Your own logs you can fix. A copy held by a vendor is governed by that vendor's settings, and you have to ask what they are.</p>
<h2>The pattern: read, return, forget</h2>
<p>The image exists only in memory, for the length of one request. What leaves the request is the reading: the extracted values. The picture does not leave at all.</p>
<ol>
<li><strong>Send the image straight to recognition.</strong> No upload bucket in between. If a bucket is unavoidable for large files, give the object a lifetime of minutes and delete it when the call returns.</li>
<li><strong>Use what you need from the response at once.</strong> Crops such as the holder's photo exist only in that response. If your flow compares a selfie to the portrait, do it now.</li>
<li><strong>Keep the reading, not the picture.</strong> Store the fields your process needs, under your own retention rule. A date of birth and a document number are still personal data, so they get an end date too.</li>
<li><strong>Keep the image out of logs and error reports.</strong> Strip bodies at the boundary, in one place, rather than trusting every caller to remember.</li>
<li><strong>Write down the result.</strong> For each place in the table above, note whether the image can reach it and why not. That note is the accountability Art. 5(2) asks for.</li>
</ol>
<h2>Questions to ask your OCR provider</h2>
<p>Because the last row of the table is the vendor's, these are the questions worth putting to any service that reads documents for you:</p>
<ul>
<li><strong>Is the uploaded image written anywhere</strong> – disk, object store, logs – or does it live only in memory for the request?</li>
<li><strong>Are the crops</strong> (the holder's photo, the signature, the page crop) stored, or returned once in the response?</li>
<li><strong>Can retention be set per request,</strong> and does "zero" mean no row at all, or a row that expires soon?</li>
<li><strong>What does the account default keep,</strong> and does shortening it apply to rows already stored?</li>
<li><strong>Can one result be deleted early</strong> through the API?</li>
<li><strong>What does a retry look like with zero retention?</strong> If nothing is stored, an idempotent retry has no first result to return; the service should say so explicitly (for example with a 409) rather than quietly running the scan twice.</li>
</ul>
<p>For one worked set of answers, the <a href="https://doc.cheap/docs/concepts/data-retention-and-privacy">doc.cheap data retention and privacy page</a> describes how that API treats each of these points: the image held in memory for the request only, crops returned once, a per-request window from zero (no row written) up to one year, and early deletion of a single result.</p>
<h2>What stays your job</h2>
<p>Read, return, forget removes the copies on the provider's side. It does not decide what your business has to keep. For some businesses the answer is "a copy", and the law says so.</p>
<p>The EU's new anti-money-laundering law, <a href="https://eur-lex.europa.eu/eli/reg/2024/1624/oj/eng">Reg. (EU) 2024/1624</a>, applies from 10 July 2027. Art. 90 says: "It shall apply from 10 July 2027, except in relation to obliged entities referred to in Article 3, points (3)(n) and (o), to which it shall apply from 10 July 2029." Its Art. 77 on record retention makes obliged entities, such as banks and other financial firms, keep:</p>
<blockquote>
<p>"a copy of the documents and information obtained in the performance of customer due diligence pursuant to Chapter III, including information obtained through electronic identification means;"</p>
</blockquote>
<p>Art. 77(3) sets the length: the records are "retained for a period of 5 years commencing on the date of the termination of the business relationship", and then "obliged entities shall delete personal data upon expiry of the five-year period". Art. 77(2) allows, under conditions, "a retention of the references to such information" in place of copies.</p>
<p>So if you are an obliged entity, zero retention at the OCR step does not remove your duty to keep a record. It changes where the record lives. Your own store becomes the one copy, and the storage limitation principle still applies to it: five years after the relationship ends, it goes. The design work is to make that store deliberate – one place, one owner, encryption, access control and a deletion job – rather than the accidental pile from the table above.</p>
<p>If you are not an obliged entity, ask the plain question first: does anything in your process need the picture after the fields are read? Often the honest answer is no.</p>
<h2>A checklist</h2>
<ul>
<li>Every place in the "where images end up" table is checked against your flow.</li>
<li>The image goes straight to recognition, or through a bucket with a lifetime of minutes.</li>
<li>Crops are used inside the response handler and not written anywhere.</li>
<li>The OCR call sets its retention on purpose, zero when nothing needs reading back.</li>
<li>Retries handle the "nothing to replay" answer of a zero-retention call.</li>
<li>Logs and error reports strip request bodies at one boundary.</li>
<li>The fields you keep have an end date, and something deletes them.</li>
<li>If a law requires a copy, it lives in one deliberate store with its own deletion date.</li>
</ul>
<p>This is an engineering summary, not legal advice. The original post on <a href="https://doc.cheap/">doc.cheap</a> also shows a zero-retention call in Python.</p>
<p><strong>A question for you:</strong> where did you last find a copy of an identity document that nobody meant to keep? Tell me in the comments.</p>
]]></content:encoded></item><item><title><![CDATA[MRZ dates have no century: reading YYMMDD without year-2000 bugs]]></title><description><![CDATA[MRZ dates are YYMMDD with no century. The rule for birth and expiry dates, the 29 February trap, and Python and JavaScript code with test vectors.]]></description><link>https://doccheap.hashnode.dev/mrz-dates-have-no-century-reading-yymmdd-without-year-2000-bugs</link><guid isPermaLink="true">https://doccheap.hashnode.dev/mrz-dates-have-no-century-reading-yymmdd-without-year-2000-bugs</guid><category><![CDATA[Python]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[algorithms]]></category><category><![CDATA[Testing]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Teoh Chin Heng]]></dc:creator><pubDate>Mon, 19 Oct 2026 01:35:55 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published at <a href="https://doc.cheap/blog/mrz-date-format-yymmdd">MRZ dates have no century: reading YYMMDD without year-2000 bugs</a></em></p>
<p>A passport's machine-readable zone (MRZ) writes every date as six digits, <code>YYMMDD</code>, and leaves the century out. ICAO's specimen passport carries <code>740812</code> as the date of birth: 1974 or 2074? A person cannot hold a passport before they are born, so it is 1974. That observation is the whole rule. This post turns it into code that is correct on every day of the year, lists the edge cases that catch simple parsers, and gives Python and JavaScript implementations with a test table. All examples use ICAO's fictitious specimen or synthetic values.</p>
<h2>Where the dates are</h2>
<p><a href="https://www.icao.int/publications/doc-series/doc-9303">ICAO Doc 9303</a> defines two date fields in each MRZ format, date of birth and date of expiry, each <code>YYMMDD</code> and each followed by a check digit. Positions below are 0-based:</p>
<table>
<thead>
<tr>
<th>Format</th>
<th>Date of birth</th>
<th>Date of expiry</th>
</tr>
</thead>
<tbody><tr>
<td>TD3 (passports), line 2</td>
<td>13–18, check digit at 19</td>
<td>21–26, check digit at 27</td>
</tr>
<tr>
<td>TD2, line 2</td>
<td>13–18, check digit at 19</td>
<td>21–26, check digit at 27</td>
</tr>
<tr>
<td>TD1 (ID cards), line 2</td>
<td>0–5, check digit at 6</td>
<td>8–13, check digit at 14</td>
</tr>
</tbody></table>
<p>On the TD3 specimen line <code>L898902C36UTO7408122F3404159ZE184226B&lt;&lt;&lt;&lt;&lt;16</code> that yields <code>740812</code> (birth) and <code>340415</code> (expiry).</p>
<h2>The problem with a pivot year</h2>
<p>The common shortcut maps two-digit years below a fixed pivot to the 2000s and the rest to the 1900s. Python's <a href="https://docs.python.org/3/library/time.html"><code>time.strptime</code></a> documents exactly this for <code>%y</code>: "values 69–99 are mapped to 1969–1999, and values 0–68 are mapped to 2000–2068."</p>
<p>Applied to a birth date, that puts someone born on 12 April 1968 (<code>680412</code>) in 2068. Moving the pivot only moves the error, and a fixed pivot drifts further from the truth every year. The correct century depends on the current date, so the current date has to be an input.</p>
<h2>Rule for birth dates</h2>
<p>Take the year as <code>2000 + YY</code>. If that full date is after today, subtract 100.</p>
<p>Three details make the difference between almost right and right:</p>
<ol>
<li><strong>Compare full dates, not years.</strong> With today = 24 September 2026, <code>261224</code> would be 24 December 2026, which has not happened yet, so the answer is 24 December 1926. A year-only comparison would call it 2026.</li>
<li><strong>Today is not the future.</strong> <code>260924</code> stays 2026-09-24; <code>260925</code> becomes 1926-09-25. The comparison is strictly "after".</li>
<li><strong>Inject today, in UTC.</strong> A function that reads the system clock cannot be tested deterministically, and machines in different time zones disagree about the date for part of each day.</li>
</ol>
<h2>Rule for expiry dates</h2>
<p>Always <code>2000 + YY</code>. An expired document from 2012 is a 2012 date, not 2112. The rule is safe until documents start expiring in the 2100s.</p>
<h2>Edge cases</h2>
<ul>
<li><strong>Leap days.</strong> Pick the century first, then validate. <code>280229</code> (birth) is 1928-02-29, a real date. <code>000229</code> is 2000-02-29, real because 2000 is a leap year; 1900 was not. Validating before choosing the century breaks one of these.</li>
<li><strong>Filler characters.</strong> Unknown parts of a date can be written as <code>&lt;</code>, for example <code>94&lt;&lt;08</code>. That is not a calendar date: return nothing and keep the raw text for display.</li>
<li><strong>Valid check digit, invalid date.</strong> The check digit guards the characters, not the calendar. With weights 7, 3, 1, <code>741312</code> sums to 88, check digit <code>8</code>, so <code>7413128</code> passes, yet month 13 does not exist. Run both checks.</li>
<li><strong>Centenarians.</strong> Born in 1925 and born in 2025 look identical. The rule chooses the younger reading; if that matters, compare with the date printed on the data page.</li>
</ul>
<h2>Python implementation</h2>
<p>Standard library only; <code>today</code> is a <code>datetime.date</code> supplied by the caller.</p>
<pre><code class="language-python">import re
from datetime import date, datetime, timezone

SIX_DIGITS = re.compile(r"[0-9]{6}")

def read_mrz_date(raw, kind, today):
    """Turn an MRZ YYMMDD field into a date, or None if it is not one.

    kind is "birth" or "expiry"; today is a datetime.date.
    """
    if kind not in ("birth", "expiry"):
        raise ValueError(f"unknown kind: {kind!r}")
    if not SIX_DIGITS.fullmatch(raw):
        return None  # fillers, letters or the wrong length
    yy, mm, dd = int(raw[0:2]), int(raw[2:4]), int(raw[4:6])
    year = 2000 + yy
    if kind == "birth" and (year, mm, dd) &gt; (today.year, today.month, today.day):
        year -= 100  # the 2000s reading is in the future
    try:
        return date(year, mm, dd)  # checked after the century is chosen
    except ValueError:
        return None  # 13th month, 31 April, 29 February in a common year

# read_mrz_date("740812", "birth", datetime.now(timezone.utc).date())
</code></pre>
<p><code>[0-9]</code> is deliberate: <code>\d</code> and <code>str.isdigit()</code> also accept digits from other scripts, and an MRZ only contains ASCII digits.</p>
<h2>JavaScript implementation</h2>
<p><code>today</code> is an ISO <code>YYYY-MM-DD</code> string; such strings sort correctly as plain strings.</p>
<pre><code class="language-javascript">export function readMrzDate(raw, kind, today /* "YYYY-MM-DD", UTC */) {
  if (kind !== "birth" &amp;&amp; kind !== "expiry") throw new Error(`unknown kind: ${kind}`);
  if (!/^[0-9]{6}$/.test(raw)) return null; // fillers, letters or the wrong length
  const month = Number(raw.slice(2, 4));
  const day = Number(raw.slice(4, 6));
  let year = 2000 + Number(raw.slice(0, 2));
  const iso = (y) =&gt; `${y}-${raw.slice(2, 4)}-${raw.slice(4, 6)}`;
  if (kind === "birth" &amp;&amp; iso(year) &gt; today) year -= 100; // the 2000s reading is in the future
  // Date.UTC rolls 31 April over to 1 May; a changed month or day means no such date.
  const d = new Date(Date.UTC(year, month - 1, day));
  if (month &lt; 1 || month &gt; 12 || d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day) {
    return null;
  }
  return iso(year);
}

// readMrzDate("740812", "birth", new Date().toISOString().slice(0, 10));
</code></pre>
<h2>Test table</h2>
<p>Today fixed at 2026-09-24; "none" is <code>None</code> / <code>null</code>.</p>
<table>
<thead>
<tr>
<th>Input</th>
<th>Kind</th>
<th>Expected</th>
<th>Reason</th>
</tr>
</thead>
<tbody><tr>
<td><code>740812</code></td>
<td>birth</td>
<td>1974-08-12</td>
<td>ICAO specimen</td>
</tr>
<tr>
<td><code>340415</code></td>
<td>expiry</td>
<td>2034-04-15</td>
<td>ICAO specimen</td>
</tr>
<tr>
<td><code>120415</code></td>
<td>expiry</td>
<td>2012-04-15</td>
<td>expired, still 2012</td>
</tr>
<tr>
<td><code>940308</code></td>
<td>birth</td>
<td>1994-03-08</td>
<td>2094 is in the future</td>
</tr>
<tr>
<td><code>150101</code></td>
<td>birth</td>
<td>2015-01-01</td>
<td>2015 is in the past</td>
</tr>
<tr>
<td><code>261224</code></td>
<td>birth</td>
<td>1926-12-24</td>
<td>later this year, so last century</td>
</tr>
<tr>
<td><code>260925</code></td>
<td>birth</td>
<td>1926-09-25</td>
<td>tomorrow, so last century</td>
</tr>
<tr>
<td><code>260924</code></td>
<td>birth</td>
<td>2026-09-24</td>
<td>today counts as the past</td>
</tr>
<tr>
<td><code>261224</code></td>
<td>expiry</td>
<td>2026-12-24</td>
<td>expiry stays in the 2000s</td>
</tr>
<tr>
<td><code>280229</code></td>
<td>birth</td>
<td>1928-02-29</td>
<td>1928 is a leap year</td>
</tr>
<tr>
<td><code>000229</code></td>
<td>birth</td>
<td>2000-02-29</td>
<td>2000 is a leap year, 1900 was not</td>
</tr>
<tr>
<td><code>270229</code></td>
<td>birth</td>
<td>none</td>
<td>1927 is not a leap year</td>
</tr>
<tr>
<td><code>240229</code></td>
<td>expiry</td>
<td>2024-02-29</td>
<td>2024 is a leap year</td>
</tr>
<tr>
<td><code>941308</code></td>
<td>birth</td>
<td>none</td>
<td>there is no month 13</td>
</tr>
<tr>
<td><code>94&lt;&lt;08</code></td>
<td>birth</td>
<td>none</td>
<td>fillers, not a date</td>
</tr>
</tbody></table>
<p>Pin "today" in your tests. With the real clock, a row such as <code>261224</code> changes its answer on 25 December.</p>
<h2>Summary</h2>
<p>Birth dates take the latest century that does not put the date in the future, compared as full dates with today counted as past and today passed in as UTC. Expiry dates stay in the 2000s. Choose the century before validating the calendar date, treat fillers as "no date", and never let a passing check digit stand in for date validation.</p>
<p>If you receive dates already converted by an OCR service, keep the raw MRZ lines alongside them so you can re-apply your own rule where a date decides something important. The original post on <a href="https://doc.cheap/">doc.cheap</a> also covers how the <a href="https://doc.cheap/mrz-checker">doc.cheap MRZ parser</a> applies the same rule in the browser.</p>
]]></content:encoded></item><item><title><![CDATA[EU Digital Identity Wallets are due by 24 December 2026: what changes for apps that scan passports]]></title><description><![CDATA[Where the 24 December 2026 wallet deadline comes from, what a wallet hands over, and why a passport or ID card scan stays as the fallback.]]></description><link>https://doccheap.hashnode.dev/eu-digital-identity-wallets-are-due-by-24-december-2026-what-changes-for-apps-that-scan-passports</link><guid isPermaLink="true">https://doccheap.hashnode.dev/eu-digital-identity-wallets-are-due-by-24-december-2026-what-changes-for-apps-that-scan-passports</guid><category><![CDATA[Security]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[architecture]]></category><category><![CDATA[privacy]]></category><dc:creator><![CDATA[Teoh Chin Heng]]></dc:creator><pubDate>Fri, 09 Oct 2026 00:43:01 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published at <a href="https://doc.cheap/blog/eu-digital-identity-wallet-deadline">EU Digital Identity Wallets are due by 24 December 2026: what changes for apps that scan passports</a></em></p>
<p>By 24 December 2026 every EU Member State has to offer its residents a European Digital Identity Wallet. For developers who maintain an identity check that starts with a passport or ID card photo, three questions follow: where exactly does that date come from, what data does a wallet hand over, and do document scans go away? This post answers each from the legal texts themselves, then sketches an architecture that supports both inputs.</p>
<h2>The deadline, counted step by step</h2>
<p>The obligation sits in Art. 5a(1) of Reg. (EU) No 910/2014, as amended by <a href="https://eur-lex.europa.eu/eli/reg/2024/1183/oj/eng">Reg. (EU) 2024/1183</a> (often called eIDAS 2). Member States must provide a wallet "within 24 months of the date of entry into force of the implementing acts referred to in paragraph 23 of this Article and in Article 5c(6)".</p>
<p>The relevant implementing acts are dated 28 November 2024; one of them is <a href="https://eur-lex.europa.eu/eli/reg_impl/2024/2979/oj/eng">Implementing Reg. (EU) 2024/2979</a>, on the integrity and core functionalities of wallets. It appeared in the Official Journal on 4 December 2024 and entered into force "on the twentieth day following that of its publication":</p>
<table>
<thead>
<tr>
<th>Step</th>
<th>Date</th>
</tr>
</thead>
<tbody><tr>
<td>Publication</td>
<td>4 December 2024</td>
</tr>
<tr>
<td>Entry into force (20th day after publication)</td>
<td>24 December 2024</td>
</tr>
<tr>
<td>Wallets due, +24 months (Art. 5a(1))</td>
<td>24 December 2026</td>
</tr>
<tr>
<td>Certain private relying parties must accept wallets, +36 months (Art. 5f(2))</td>
<td>24 December 2027</td>
</tr>
</tbody></table>
<p>The 20 days count from the day after publication, which is why the result is the 24th and not the 23rd.</p>
<h2>The data a wallet presents</h2>
<p>Instead of an image, a wallet presents a signed credential. For a natural person the attribute set is "person identification data" (PID), defined in the Annex to <a href="https://eur-lex.europa.eu/eli/reg_impl/2024/2977/oj/eng">Implementing Reg. (EU) 2024/2977</a> and issued in ISO/IEC 18013-5:2021 or W3C "Verifiable Credentials Data Model 1.1" format.</p>
<p>Mandatory attributes about the person:</p>
<ul>
<li><code>family_name</code>, <code>given_name</code>, <code>birth_date</code>, <code>birth_place</code></li>
<li><code>nationality</code> (ISO 3166-1 alpha-2, one or more values)</li>
</ul>
<p>Mandatory metadata:</p>
<ul>
<li><code>expiry_date</code> (of the PID, not of any physical document)</li>
<li><code>issuing_authority</code>, <code>issuing_country</code> (alpha-2)</li>
</ul>
<p>Everything else is optional: the <code>resident_*</code> address attributes, <code>personal_administrative_number</code>, <code>portrait</code>, birth names, <code>sex</code> (numeric codes 0, 1, 2, 3, 4, 5, 6, 9), <code>email_address</code>, <code>mobile_phone_number</code>, plus metadata such as <code>document_number</code>, <code>issuing_jurisdiction</code> and <code>location_status</code>.</p>
<p>How this compares with what you read off a passport data page:</p>
<table>
<thead>
<tr>
<th>Topic</th>
<th>PID (wallet)</th>
<th>Passport data page / MRZ</th>
</tr>
</thead>
<tbody><tr>
<td>Country codes</td>
<td>alpha-2 (<code>DE</code>)</td>
<td>alpha-3 (<code>DEU</code>)</td>
</tr>
<tr>
<td>Nationality</td>
<td>one or more</td>
<td>one</td>
</tr>
<tr>
<td>Sex</td>
<td>numeric codes</td>
<td><code>M</code>, <code>F</code> or <code>X</code> (<code>&lt;</code> in the MRZ when unspecified)</td>
</tr>
<tr>
<td>Address</td>
<td>optional attributes</td>
<td>not printed</td>
</tr>
<tr>
<td>Document number</td>
<td>assigned by the PID provider</td>
<td>the passport's own number</td>
</tr>
<tr>
<td>Expiry</td>
<td>of the credential</td>
<td>of the document</td>
</tr>
<tr>
<td>Unknown value</td>
<td>a placeholder "appropriate to the situation"</td>
<td>often simply absent</td>
</tr>
</tbody></table>
<p>The last row deserves attention: the Annex says that where a mandatory value "is not known for the person or cannot otherwise be issued", Member States "shall use an attribute value appropriate to the situation instead". Your mapping code should expect placeholder values, not missing keys.</p>
<h2>Why document input does not disappear</h2>
<p>Art. 5a(15) is explicit: "The use of European Digital Identity Wallets shall be voluntary", and "It shall remain possible to access public and private services by other existing identification and authentication means."</p>
<p>In practice, document input remains necessary for:</p>
<ul>
<li><strong>Non-EU travellers and customers.</strong> The wallet is tied to "the legal identity of Union citizens, residents in the Union or legal persons".</li>
<li><strong>Users who have no wallet</strong>, by choice or circumstance.</li>
<li><strong>Processes that need the document itself</strong> – its number, image or machine-readable zone – rather than an attestation about the person.</li>
<li><strong>Rollout gaps.</strong> The legal deadline is not the date a particular national wallet reaches users; check that country's own announcement.</li>
</ul>
<h2>One record, two inputs</h2>
<p>A pattern that holds up:</p>
<pre><code class="language-text">            +--&gt; wallet presentation --&gt; verify signature --&gt; map PID ------+
user -------+                                                               +--&gt; identity record
            +--&gt; document photo --&gt; OCR / MRZ read --&gt; map document fields -+
</code></pre>
<p>Implementation notes:</p>
<ol>
<li><strong>Define your own internal schema</strong> and write two adapters into it, one per input.</li>
<li><strong>Normalise country codes at the edge</strong> (pick alpha-2 or alpha-3 and convert the other).</li>
<li><strong>Record the source</strong> of every identity record. A signed wallet presentation and an OCR read are different kinds of evidence, and an auditor will ask which one you relied on.</li>
<li><strong>Unify "unknown".</strong> Map PID placeholders and OCR nulls to one representation.</li>
<li><strong>Verify what you can on the document path.</strong> The MRZ check digits (ICAO Doc 9303, weights 7-3-1) catch most misreads; the TD1, TD2 and TD3 layouts are compared on the <a href="https://doc.cheap/mrz-formats">doc.cheap MRZ formats page</a>. Keep in mind that check digits prove the zone was read consistently, not that the document is genuine.</li>
<li><strong>Minimise storage.</strong> If the flow only needs a few fields, keep only those.</li>
</ol>
<p>An adapter for the document side can be as small as this (shown for a JSON response with <code>holder</code>, <code>document</code> and <code>mrz</code> objects; adjust the paths to whatever your OCR returns):</p>
<pre><code class="language-javascript">function fromDocument(read) {
  return {
    source: "document_scan",
    family_name: read.holder?.surname ?? null,
    given_name: read.holder?.given_names ?? null,
    birth_date: read.holder?.birth_date ?? null,
    nationality_alpha3: read.holder?.nationality ?? null,
    issuing_country_alpha3: read.document?.issuing_state ?? null,
    document_number: read.document?.number ?? null,
    mrz_status: read.mrz?.status ?? null,
  };
}
</code></pre>
<p>The wallet adapter is the mirror image: verify the presentation's signature first, then map <code>family_name</code>, <code>given_name</code>, <code>birth_date</code>, <code>nationality</code> and <code>issuing_country</code>, converting the alpha-2 codes.</p>
<h2>Dates to keep on the calendar</h2>
<ul>
<li><strong>24 December 2026</strong> – Member States must provide wallets (Art. 5a(1)).</li>
<li><strong>24 December 2027</strong> – private relying parties that are required, by law or contract, to use strong user authentication for online identification must also accept wallets on the user's voluntary request (Art. 5f(2)). Sectors named include transport, energy, banking, financial services, social security, health, drinking water, postal services, digital infrastructure, education and telecommunications; micro and small businesses are excluded.</li>
<li><strong>Very large online platforms</strong> must accept wallets for user authentication on the user's voluntary request (Art. 5f(3)).</li>
<li><strong>Revisions.</strong> Recital 4 of 2024/2979 says the Commission "should review and update" the act where necessary, so re-check the 2024/2977 attribute tables before freezing a mapping.</li>
</ul>
<h2>Summary</h2>
<p>The wallet deadline is a Member State obligation, wallet use stays voluntary, and document input keeps a permanent place for non-EU users and anyone without a wallet. Designing one internal identity record with two adapters lets you add the wallet path when your users' countries are ready without rewriting the document path.</p>
<p>This is an engineering summary, not legal advice. The original post on <a href="https://doc.cheap/">doc.cheap</a> includes the full attribute-by-field mapping table.</p>
]]></content:encoded></item><item><title><![CDATA[How passport MRZ check digits work (with a validator in Python and JavaScript)]]></title><description><![CDATA[The 7-3-1 algorithm behind every passport and ID card MRZ, where each check digit sits in TD1, TD2 and TD3, and a validator you can paste.]]></description><link>https://doccheap.hashnode.dev/how-mrz-check-digits-work</link><guid isPermaLink="true">https://doccheap.hashnode.dev/how-mrz-check-digits-work</guid><category><![CDATA[Python]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[algorithms]]></category><category><![CDATA[Tutorial]]></category><category><![CDATA[OCR ]]></category><dc:creator><![CDATA[Teoh Chin Heng]]></dc:creator><pubDate>Thu, 24 Sep 2026 06:39:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ab4aa49a61f2c59c8fc4484/6bd30556-f061-4c37-a0c4-e3bf636e862e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Originally published at</em> <a href="https://doc.cheap/blog/how-mrz-check-digits-work"><em>How passport MRZ check digits work (with a validator in Python and JavaScript)</em></a><em>.</em></p>
<p>Every passport has two lines of angry-looking text at the bottom of the photo page: capital letters, digits and a lot of <code>&lt;</code> signs. That is the machine-readable zone, or MRZ, and it is defined by ICAO Doc 9303, the standard for travel documents. It holds the same core facts as the printed page (name, document number, nationality, date of birth, sex, expiry date), in a form a scanner can read without guessing at fonts or layouts.</p>
<p>It also carries its own error detection. A few of the characters are check digits: each one is computed from a specific field, and one final digit covers several fields at once. If a single character is misread, the digit that protects it usually stops matching. That makes the MRZ one of the few things in identity-document processing you can verify yourself, with twenty lines of code and no trust in anybody's OCR.</p>
<p>This post walks through the algorithm, where the digits sit in each of the three MRZ formats, and a validator in Python and JavaScript that you can paste into a project. Every example uses ICAO's own fictitious specimen, Anna Maria Eriksson of "Utopia" (<code>UTO</code>, a country code that exists only in specimens). No real document appears anywhere.</p>
<p><em>This is the blog of doc.cheap, a document-recognition API that reads the MRZ and re-checks these digits on the server. Nothing below needs it; the code runs offline.</em></p>
<h2>The alphabet</h2>
<p>An MRZ uses exactly 37 characters: <code>0-9</code>, <code>A-Z</code> and the filler <code>&lt;</code>. There are no lower-case letters, no spaces and no punctuation. Names with accents or non-Latin scripts are transliterated, and spaces inside a field become <code>&lt;</code>. The filler also pads every field to its fixed width, so <code>ERIKSSON&lt;&lt;ANNA&lt;MARIA&lt;&lt;&lt;&lt;&lt;&lt;&lt;</code> is "surname ERIKSSON, given names ANNA MARIA", with the double <code>&lt;&lt;</code> separating surname from given names.</p>
<h2>The algorithm: weights 7, 3, 1</h2>
<p>A check digit is computed the same way for every field of every format:</p>
<ol>
<li><p><strong>Turn each character into a number.</strong> A digit is its own value. A letter is its position in the alphabet plus 9, so <code>A</code> = 10, <code>B</code> = 11, … <code>Z</code> = 35. The filler <code>&lt;</code> is 0.</p>
</li>
<li><p><strong>Multiply by a repeating weight</strong> of 7, 3, 1, 7, 3, 1, … starting from the first character of the field.</p>
</li>
<li><p><strong>Add the products and take the remainder modulo 10.</strong> That single digit is the check digit.</p>
</li>
</ol>
<p>Worked through on the specimen passport number, <code>L898902C3</code>, whose printed check digit is <code>6</code>:</p>
<pre><code class="language-text">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        316 mod 10 = 6        the zone prints 6
</code></pre>
<p>Why 7-3-1? The weights are chosen so that the most common reading errors change the sum: a single wrong character, and many swaps of two adjacent characters. It is not a cryptographic checksum. Anyone can compute it, so a matching digit proves only that the zone is internally consistent, not that the document is genuine.</p>
<h2>The three formats</h2>
<p>ICAO 9303 defines three MRZ layouts. The number of lines and the characters per line tell them apart:</p>
<table>
<thead>
<tr>
<th>Format</th>
<th>Lines × characters</th>
<th>Where you meet it</th>
</tr>
</thead>
<tbody><tr>
<td>TD1</td>
<td>3 × 30</td>
<td>ID cards, residence permits</td>
</tr>
<tr>
<td>TD2</td>
<td>2 × 36</td>
<td>Older ID cards and some travel documents</td>
</tr>
<tr>
<td>TD3</td>
<td>2 × 44</td>
<td>Passport booklets</td>
</tr>
</tbody></table>
<p>The specimens used below:</p>
<pre><code class="language-text">TD3  P&lt;UTOERIKSSON&lt;&lt;ANNA&lt;MARIA&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;
     L898902C36UTO7408122F1204159ZE184226B&lt;&lt;&lt;&lt;&lt;10

TD2  I&lt;UTOERIKSSON&lt;&lt;ANNA&lt;MARIA&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;
     D231458907UTO7408122F1204159&lt;&lt;&lt;&lt;&lt;&lt;&lt;6

TD1  I&lt;UTOD231458907&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;
     7408122F1204159UTO&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;6
     ERIKSSON&lt;&lt;ANNA&lt;MARIA&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;
</code></pre>
<p>Read the TD3 second line left to right: <code>L898902C3</code> document number, <code>6</code> its check digit, <code>UTO</code> nationality, <code>740812</code> date of birth (YYMMDD), <code>2</code> its check digit, <code>F</code> sex, <code>120415</code> expiry date, <code>9</code> its check digit, <code>ZE184226B&lt;&lt;&lt;&lt;&lt;</code> optional data (often a personal number), <code>1</code> its check digit, and finally <code>0</code>, the composite check digit.</p>
<p>The <a href="https://doc.cheap/mrz-checker">MRZ parser</a> has the position of every field and check digit in all three as a reference table, and the <a href="https://doc.cheap/mrz-formats">MRZ formats page</a> walks through each layout.</p>
<h2>Where each check digit sits</h2>
<p>Positions are 0-based, so they drop straight into <code>slice</code>. Each field's check digit sits immediately after the field.</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>TD3 (line 2)</th>
<th>TD2 (line 2)</th>
<th>TD1</th>
</tr>
</thead>
<tbody><tr>
<td>Document number</td>
<td>0–8, digit at 9</td>
<td>0–8, digit at 9</td>
<td>line 1: 5–13, digit at 14</td>
</tr>
<tr>
<td>Date of birth</td>
<td>13–18, digit at 19</td>
<td>13–18, digit at 19</td>
<td>line 2: 0–5, digit at 6</td>
</tr>
<tr>
<td>Expiry date</td>
<td>21–26, digit at 27</td>
<td>21–26, digit at 27</td>
<td>line 2: 8–13, digit at 14</td>
</tr>
<tr>
<td>Optional data</td>
<td>28–41, digit at 42</td>
<td>none</td>
<td>none</td>
</tr>
<tr>
<td>Composite</td>
<td>digit at 43</td>
<td>digit at 35</td>
<td>line 2: digit at 29</td>
</tr>
</tbody></table>
<p>The composite digit is where most home-made validators go wrong, because it does not cover the whole line:</p>
<ul>
<li><p><strong>TD3:</strong> positions 0–9, 13–19 and 21–42 of line 2. It skips the nationality (10–12) and the sex (20).</p>
</li>
<li><p><strong>TD2:</strong> positions 0–9, 13–19 and 21–34 of line 2. Same skips.</p>
</li>
<li><p><strong>TD1:</strong> it spans two lines: line 1 positions 5–29, then line 2 positions 0–6, 8–14 and 18–28.</p>
</li>
</ul>
<p>Each range includes the per-field check digits inside it, which is what makes the composite catch errors in the digits themselves.</p>
<h2>A validator in Python</h2>
<p>No dependencies. It detects the format from the shape, checks every field digit and the composite, and returns a dict of results.</p>
<pre><code class="language-python">WEIGHTS = (7, 3, 1)

def char_value(c):
    if c.isdigit():
        return int(c)
    if "A" &lt;= c &lt;= "Z":
        return ord(c) - ord("A") + 10
    if c == "&lt;":
        return 0
    raise ValueError(f"not an MRZ character: {c!r}")

def check_digit(data):
    return sum(char_value(c) * WEIGHTS[i % 3] for i, c in enumerate(data)) % 10

def digit_ok(data, printed):
    # A field made only of fillers may print "&lt;" as its check digit.
    expected = 0 if printed == "&lt;" else int(printed)
    return check_digit(data) == expected

# (name, line index, start, end, check-digit position) per format
LAYOUTS = {
    "TD3": [("document number", 1, 0, 9, 9), ("birth date", 1, 13, 19, 19),
            ("expiry date", 1, 21, 27, 27), ("personal number", 1, 28, 42, 42)],
    "TD2": [("document number", 1, 0, 9, 9), ("birth date", 1, 13, 19, 19),
            ("expiry date", 1, 21, 27, 27)],
    "TD1": [("document number", 0, 5, 14, 14), ("birth date", 1, 0, 6, 6),
            ("expiry date", 1, 8, 14, 14)],
}

def composite(fmt, lines):
    if fmt == "TD3":
        l = lines[1]
        return l[0:10] + l[13:20] + l[21:43], l[43]
    if fmt == "TD2":
        l = lines[1]
        return l[0:10] + l[13:20] + l[21:35], l[35]
    a, b = lines[0], lines[1]
    return a[5:30] + b[0:7] + b[8:15] + b[18:29], b[29]

def detect(lines):
    shape = (len(lines), len(lines[0]))
    fmt = {(2, 44): "TD3", (2, 36): "TD2", (3, 30): "TD1"}.get(shape)
    if fmt is None or any(len(l) != shape[1] for l in lines):
        raise ValueError(f"unknown MRZ shape: {[len(l) for l in lines]}")
    return fmt

def validate(lines):
    fmt = detect(lines)
    results = {}
    for name, li, start, end, pos in LAYOUTS[fmt]:
        results[name] = digit_ok(lines[li][start:end], lines[li][pos])
    data, printed = composite(fmt, lines)
    results["composite"] = digit_ok(data, printed)
    return fmt, results

if __name__ == "__main__":
    print(*validate(["P&lt;UTOERIKSSON&lt;&lt;ANNA&lt;MARIA&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;",
                     "L898902C36UTO7408122F1204159ZE184226B&lt;&lt;&lt;&lt;&lt;10"]))
    print(*validate(["I&lt;UTOERIKSSON&lt;&lt;ANNA&lt;MARIA&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;",
                     "D231458907UTO7408122F1204159&lt;&lt;&lt;&lt;&lt;&lt;&lt;6"]))
    print(*validate(["I&lt;UTOD231458907&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;",
                     "7408122F1204159UTO&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;6",
                     "ERIKSSON&lt;&lt;ANNA&lt;MARIA&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;"]))
    # One misread character: 3 read as 4 in the document number
    print(*validate(["P&lt;UTOERIKSSON&lt;&lt;ANNA&lt;MARIA&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;",
                     "L898902C46UTO7408122F1204159ZE184226B&lt;&lt;&lt;&lt;&lt;10"]))
</code></pre>
<p>Output:</p>
<pre><code class="language-text">TD3 {'document number': True, 'birth date': True, 'expiry date': True, 'personal number': True, 'composite': True}
TD2 {'document number': True, 'birth date': True, 'expiry date': True, 'composite': True}
TD1 {'document number': True, 'birth date': True, 'expiry date': True, 'composite': True}
TD3 {'document number': False, 'birth date': True, 'expiry date': True, 'personal number': True, 'composite': False}
</code></pre>
<p>The last line is the point of the whole exercise: one character misread as a neighbour, and both the field digit and the composite flag it.</p>
<h2>The same validator in JavaScript</h2>
<p>Plain ES module, runs in Node or a browser.</p>
<pre><code class="language-javascript">const WEIGHTS = [7, 3, 1];

function charValue(c) {
  if (c &gt;= "0" &amp;&amp; c &lt;= "9") return c.charCodeAt(0) - 48;
  if (c &gt;= "A" &amp;&amp; c &lt;= "Z") return c.charCodeAt(0) - 55; // A = 10
  if (c === "&lt;") return 0;
  throw new Error(`not an MRZ character: ${JSON.stringify(c)}`);
}

export function checkDigit(data) {
  let sum = 0;
  for (let i = 0; i &lt; data.length; i++) sum += charValue(data[i]) * WEIGHTS[i % 3];
  return sum % 10;
}

const digitOk = (data, printed) =&gt; checkDigit(data) === (printed === "&lt;" ? 0 : Number(printed));

const LAYOUTS = {
  TD3: [["document number", 1, 0, 9], ["birth date", 1, 13, 19], ["expiry date", 1, 21, 27], ["personal number", 1, 28, 42]],
  TD2: [["document number", 1, 0, 9], ["birth date", 1, 13, 19], ["expiry date", 1, 21, 27]],
  TD1: [["document number", 0, 5, 14], ["birth date", 1, 0, 6], ["expiry date", 1, 8, 14]],
};

function composite(fmt, [a, b]) {
  if (fmt === "TD3") return [b.slice(0, 10) + b.slice(13, 20) + b.slice(21, 43), b[43]];
  if (fmt === "TD2") return [b.slice(0, 10) + b.slice(13, 20) + b.slice(21, 35), b[35]];
  return [a.slice(5, 30) + b.slice(0, 7) + b.slice(8, 15) + b.slice(18, 29), b[29]];
}

export function validate(lines) {
  const fmt = { "2x44": "TD3", "2x36": "TD2", "3x30": "TD1" }[`${lines.length}x${lines[0].length}`];
  if (!fmt || lines.some((l) =&gt; l.length !== lines[0].length)) throw new Error("unknown MRZ shape");
  const results = {};
  // The check digit sits right after the field it protects.
  for (const [name, li, start, end] of LAYOUTS[fmt]) {
    results[name] = digitOk(lines[li].slice(start, end), lines[li][end]);
  }
  const [data, printed] = composite(fmt, lines);
  results.composite = digitOk(data, printed);
  return { format: fmt, results };
}

console.log(validate([
  "P&lt;UTOERIKSSON&lt;&lt;ANNA&lt;MARIA&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;",
  "L898902C36UTO7408122F1204159ZE184226B&lt;&lt;&lt;&lt;&lt;10",
]));
</code></pre>
<p><code>node mrz.mjs</code> prints <code>format: 'TD3'</code> and <code>true</code> for all five checks.</p>
<h2>The traps</h2>
<p><strong>Do not trim the fillers.</strong> The <code>&lt;</code> characters are part of the data the digits are computed over. Strip trailing <code>&lt;</code> from a line and the composite fails on a perfectly good document.</p>
<p><strong>Do not rebuild the zone from parsed fields.</strong> If you parse the MRZ into fields, normalise them (dates to ISO, names with spaces) and then re-serialise to check the digits, you are checking your own serialiser. Check the raw lines as they were read.</p>
<p><strong>Normalise OCR output before you validate, carefully.</strong> OCR engines like to return lower-case letters, spaces, or <code>«</code> for <code>&lt;</code>. Upper-casing and stripping whitespace is safe. Replacing <code>O</code> with <code>0</code> "because document numbers are numeric" is not: document numbers can contain letters, which is exactly what <code>L898902C3</code> shows.</p>
<p><strong>A passing digit is not a real date.</strong> <code>740812</code> passes its check digit whether or not the 12th of August 1974 is plausible, and YYMMDD has no century. Decide the century from context: a birth date is in the past, an expiry date usually in the future.</p>
<p><strong>Long document numbers on TD1.</strong> ICAO lets a TD1 document number longer than nine characters overflow into the optional-data field, with a <code>&lt;</code> in the normal check-digit position and the check digit after the last character of the number. The validator above does not handle that case. If you process ID cards from issuers that use it, add a branch; the <a href="https://doc.cheap/mrz-checker">MRZ parser</a> handles it, if you want something to compare against.</p>
<p><strong>Check digits are not authenticity.</strong> Anyone who can edit an image can compute a valid digit. The MRZ tells you the zone was read correctly and is internally consistent, not that the document is genuine. Comparing the MRZ against the printed visual zone is a stronger signal, and even that is not a forgery check.</p>
<h2>Test data without real passports</h2>
<p>You should never need a real person's passport to test this code. Two options:</p>
<ul>
<li><p>The ICAO specimens above, which are published for exactly this purpose.</p>
</li>
<li><p>Generate your own: <a href="https://doc.cheap/mrz-generator">the MRZ generator</a> builds a synthetic TD3 zone with correct check digits from values you type, in the browser. Change one character afterwards and you have a failing case.</p>
</li>
</ul>
<p>For the opposite direction, paste any zone (TD1, TD2 or TD3) into the <a href="https://doc.cheap/mrz-checker">MRZ parser</a>: it detects the format, reads every field and shows each computed check digit beside the printed one, all in the browser. That is handy when your implementation and someone else's disagree.</p>
<h2>Where this fits in a real pipeline</h2>
<p>If you read MRZs with your own OCR, run these checks on every read and treat a failure as "re-photograph", not "reject the person": glare over one character, a worn laminate or a creased page are far more common than fraud.</p>
<p>If you use a hosted recognition API instead, re-run the digits yourself anyway when the result decides money or access. It is the one part of the answer you can check without trusting the vendor. That includes us: the doc.cheap response publishes the zone verbatim as <code>mrz.lines</code> and <code>mrz.text</code> (the lines joined with nothing between them) next to its own <code>mrz.status</code> verdict, precisely so you can feed it to a function like the one above. <a href="https://doc.cheap/docs/guides/check-an-mrz">Check an MRZ</a> in the docs covers that flow.</p>
<p>If you spot a case the validator gets wrong, write to <a href="mailto:admin@doc.cheap">admin@doc.cheap</a>.</p>
<p><em>Written with the help of AI. Both code blocks were run and their output is pasted as printed; every statement about doc.cheap was checked against its code.</em></p>
]]></content:encoded></item></channel></rss>