DEV Community

Teoh Chin Heng for doc.cheap

Posted on Originally published at doc.cheap

EU Digital Identity Wallets are due by 24 December 2026: what changes for apps that scan passports

Originally published at EU Digital Identity Wallets are due by 24 December 2026: what changes for apps that scan passports

"EU wallets are coming at the end of 2026" has been in a lot of product roadmaps this year. If your signup or onboarding flow asks for a photo of a passport or ID card, it is worth knowing what that sentence actually means. The short version: the date is exact (24 December 2026), it binds the Member States, not you, and it does not switch off document scans.

Below: where the date comes from (with the arithmetic), what a wallet actually hands over (from the attribute tables in the rules), and a design that accepts a wallet when the user has one and falls back to a document scan when they do not.

Disclosure: this was written for the blog of doc.cheap, which reads document images. It does not read wallets, and nothing in the first half of the post depends on it.

Where 24 December 2026 comes from

The obligation is Art. 5a(1) of Reg. (EU) No 910/2014, inserted by Reg. (EU) 2024/1183, the eIDAS amendment. Each Member State "shall provide at least one European Digital Identity 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)".

So the clock starts with the Commission's implementing acts, not with the amendment. The Art. 5a(23) acts are four implementing acts dated 28 November 2024, among them Implementing Reg. (EU) 2024/2979 on the integrity and core functionalities of wallets. It was published in the Official Journal on 4 December 2024, and its Art. 15 says it "shall enter into force on the twentieth day following that of its publication".

Step Date
Published in the Official Journal 4 December 2024
Day 1 after publication 5 December 2024
Day 20 after publication: entry into force 24 December 2024
Plus 24 months (Art. 5a(1)): wallets due 24 December 2026
Plus 36 months (Art. 5f(2)): some private relying parties must accept wallets 24 December 2027

The "twentieth day following" counts from the day after publication, so it lands on the 24th, not the 23rd.

What a wallet hands over

A wallet does not send a picture of a passport. It presents signed data. For a natural person the data set is fixed in the Annex to Implementing Reg. (EU) 2024/2977: "person identification data" (PID), issued in two formats, ISO/IEC 18013-5:2021 and the W3C "Verifiable Credentials Data Model 1.1".

Here are the attributes and their presence (Tables 1, 2 and 5 of the Annex as published on 4 December 2024), next to the nearest field in a typical passport scan response (the field names are doc.cheap's, but most scan APIs have an equivalent):

PID attribute Presence Nearest field in a document scan
family_name mandatory holder.surname
given_name mandatory holder.given_names
birth_date mandatory holder.birth_date (ISO 8601)
birth_place mandatory a birth_place field, when the document prints one
nationality mandatory (alpha-2, one or more) holder.nationality (alpha-3, one)
resident_address and the other resident_* attributes optional none on a passport data page
personal_administrative_number optional not the same thing; a document may print a personal_number
portrait optional the portrait image
family_name_birth, given_name_birth optional no curated field
sex optional (codes 0, 1, 2, 3, 4, 5, 6, 9) holder.sex (M, F, X)
email_address, mobile_phone_number optional not on a document
expiry_date (metadata) mandatory document.expiry_date, but of the document, not of the PID
issuing_authority (metadata) mandatory an authority field, when printed
issuing_country (metadata) mandatory (alpha-2) document.issuing_state (alpha-3)
document_number (metadata) optional not the same thing: assigned by the PID provider, not the passport's number
issuing_jurisdiction, location_status (metadata) optional none

Three things bite when you write the mapping code:

  • Country codes differ. PID uses ISO 3166-1 alpha-2 (DE); passports and the machine-readable zone use alpha-3 (DEU). Keep one internal form and convert at the edge.
  • "Mandatory" does not mean "always known". The Annex adds that where a value "is not known for the person or cannot otherwise be issued", Member States "shall use an attribute value appropriate to the situation instead". Expect placeholders, not missing keys.
  • Only five attributes about the person are mandatory. Address, portrait, sex and birth names are optional. If your flow needs one of them, a given user's wallet may not carry it.

Who still shows up with a document

The deadline obliges each Member State to provide a wallet, not anyone to use one. Art. 5a(15): "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."

So after 24 December 2026 you will still see:

  • Customers from outside the EU. The recitals tie the wallet to "the legal identity of Union citizens, residents in the Union or legal persons". A visitor with a passport from elsewhere has no EU wallet.
  • People who have not installed one, or cannot, or would rather not.
  • Flows that need the document itself: its image, its number or its machine-readable zone, not an attestation about the person.
  • The months before a given country's wallet is live. The legal deadline and the day a national wallet reaches users are different questions; the only reliable answer for one country is its own announcement or the Commission's.

A design that takes both

Ask for a wallet presentation when the user has one, fall back to a document scan when they do not, and end both paths in the same internal record.

user starts verification
        |
        v
  offers a wallet? --yes--> wallet presentation --> verify signature --> map PID
        |                                                               |
        no                                                              v
        |                                                      internal identity
        v                                                          record
  document photo --> scan API --> map scan fields ----------------------^
Enter fullscreen mode Exit fullscreen mode

Four rules keep the paths interchangeable:

  1. One record, your own field names. Use the table above as the mapping.
  2. Keep the source. Store whether a record came from a wallet or a scan; they carry different evidence.
  3. Normalise empties. A scan may return null for an unknown value; a wallet may send a placeholder. Pick one convention.
  4. Store as little as the flow needs.

Here is the fallback in Node with plain fetch, against the public sandbox key sk_sandbox_public (printed in the docs, no signup, rate limited per client address):

import { readFileSync } from "node:fs";
import { randomUUID } from "node:crypto";

async function scanDocument(path, apiKey = "sk_sandbox_public") {
  const response = await fetch("https://api.doc.cheap/v1/scans", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": randomUUID(), // a retry returns the first result
    },
    body: JSON.stringify({
      image: readFileSync(path).toString("base64"),
      options: { retain_hours: 0, return_portrait: false }, // nothing stored
    }),
  });
  if (!response.ok) throw new Error(`scan failed: HTTP ${response.status}`);
  return response.json();
}

// Map a scan into the same record a wallet presentation would fill.
function fromScan(scan) {
  if (scan.meta.status !== "recognized") return null;
  const field = (name) => scan.fields.find((f) => f.id === `${name}@0`)?.value ?? null;
  return {
    source: "document_scan",
    family_name: scan.holder?.surname ?? null,
    given_name: scan.holder?.given_names ?? null,
    birth_date: scan.holder?.birth_date ?? null,
    birth_place: field("birth_place"),
    nationality_alpha3: scan.holder?.nationality ?? null,
    issuing_country_alpha3: scan.document?.issuing_state ?? null,
    document_number: scan.document?.number ?? null,
    mrz_status: scan.mrz.status, // "passed", "failed" or "absent"
  };
}
Enter fullscreen mode Exit fullscreen mode

meta.status is one of recognized, no_document_found, unreadable, unsupported_document or rejected. Only the first should fill a record; the rest should send the user back to retake the photo. A passport data page carries its MRZ as two lines in the TD3 layout; the doc.cheap MRZ formats page shows TD1, TD2 and TD3 side by side.

One caveat that matters for compliance people: a scan like this is recognition. It reads what is printed and checks the MRZ check digits; the response says authenticity.overall: "not_checked". It is not forgery detection, and not the same assurance as a signed wallet presentation. If your process needs more on the scan path, it has to come from elsewhere in the flow – the doc.cheap passport OCR API comparison lists providers, including ones that do more than recognition.

What to watch next

  • 24 December 2026 – wallets due (Art. 5a(1)).
  • 24 December 2027 – private relying parties. Art. 5f(2): those that must use strong user authentication for online identification, by law or by contract, "shall ... only upon the voluntary request of the user, also accept European Digital Identity Wallets". The text names transport, energy, banking, financial services, social security, health, drinking water, postal services, digital infrastructure, education and telecommunications, and excludes micro and small businesses.
  • Very large online platforms. Art. 5f(3) makes them accept wallets for user authentication, again on the user's voluntary request and for the minimum data needed.
  • Changes to the implementing acts. Recital 4 of 2024/2979 says the Commission "should review and update" it where necessary. Re-read the 2024/2977 attribute tables before you freeze a mapping.

Takeaway: build the wallet path when your users' countries ship their wallets, keep the document path, and map both into one record from day one.

This is an engineering summary, not legal advice. Is your team planning to accept the wallet, keep the scan, or both? I would like to hear how others are approaching it in the comments.

Top comments (3)

Collapse
 
mickyarun profile image
arun rajkumar •

The arithmetic on the date is the part of this I had not seen done properly anywhere. The twentieth day counted from the day after publication is exactly the kind of thing that puts a roadmap one day out and nobody notices until it matters.

Two things from the payments side, where we have had to live with mixed-provenance identity for a while.

The date that changes your product is 24 December 2027, not 2026. Art. 5f obliges private relying parties to accept wallets where strong user authentication for online identification is already required by Union or national law. That is us, and it is lending, and it is anything with a regulated onboarding obligation. The 2026 date gives you a wallet that exists. The 2027 date gives you a wallet you must accept. Only one of those is optional and they are very different budgets.

The mapping table is right and what it hides is provenance. A PID attribute and the same attribute read off a document are not the same claim. One is signed by a PID provider at a known assurance level; the other is OCR plus whatever your liveness check concluded. If your schema has one nationality column and both paths write to it, you have discarded the only field that matters when something goes wrong later, which is which one you relied on. In card payments that distinction is the entire liability question — whether the transaction was strongly authenticated decides who carries the fraud, not whether the data was correct. Identity will land in the same place. Keep source and assurance level per attribute, not per record.

One concrete trap in the alpha-3 conversion, and it is worse than a width mismatch. The codes in a passport's machine-readable zone are ICAO Doc 9303 codes, not ISO 3166-1 alpha-3, and they do not all agree. Germany's MRZ issuing-state code is D, not DEU. British nationality classes carry GBD, GBN, GBO, GBP and GBS alongside GBR, and none of those exist in ISO 3166 at all. A naive alpha-3 to alpha-2 map turns a British National (Overseas) into GB or into a null, and both are wrong in a way that only surfaces when someone is declined and complains. Convert ICAO to ISO explicitly, against a table you own, and keep the raw MRZ code.

Does doc.cheap return the raw MRZ issuing-state code alongside the normalised one, or only the normalised value?

Collapse
 
doccheap profile image
Teoh Chin Heng doc.cheap •

Thanks – all three points are fair, and the 2027 acceptance date is the one relying parties should plan around.

On your question: the raw code is there. mrz.lines returns the zone exactly as read, so the issuing-state code (D<< for Germany, GBD and the other British codes) sits on line 1, positions 3–5, untouched. document.issuing_state is not remapped on our side: a three-letter code passes through as it was read, and anything else gives null rather than a guessed DEU. The flip side is that GBD comes through as GBD, which is a Doc 9303 code, not an ISO 3166 one – treat the field as the zone's code.

Per-attribute provenance is thinner today: each field carries a confidence band and the language it was read in, and mrz.status says whether the zone's check digits passed and agree with the printed page, but a field does not say which zone its value came from.

Collapse
 
doccheap profile image
Teoh Chin Heng doc.cheap •

A follow-up, because your point changed the API. My earlier answer said a German D<< comes back as null rather than a guessed DEU. That is no longer true.

Since version 1.72.0:

  • issuing_state and nationality map Germany's D to DEU. Every other code comes back as it did before, so nothing breaks for existing callers.
  • Two new fields, issuing_state_detail and nationality_detail, give { code, alpha3, alpha2, category }. code is what the document prints, read from the MRZ itself. alpha3 and alpha2 are the ISO 3166-1 codes, or null where ISO has none. category says what kind of code it is: iso, icao_variant (D), british_nationality (GBD, GBN, GBO, GBP, GBS, which resolve to GBR / GB), kosovo (RKS), united_nations (UNO, UNA, UNK), organization (EUE and the other Doc 9303 organisation codes), stateless (XXA), refugee (XXB, XXC), unspecified (XXX), deprecated, specimen (UTO) or unknown.

So RKS, XXA and the UN codes are returned exactly as written, with alpha3 null, instead of being guessed into an ISO code.

The table is here: doc.cheap/docs/reference/response#...

Thanks for the push – it was a real bug.

Teoh Chin Heng