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 ----------------------^
Four rules keep the paths interchangeable:
- One record, your own field names. Use the table above as the mapping.
- Keep the source. Store whether a record came from a wallet or a scan; they carry different evidence.
-
Normalise empties. A scan may return
nullfor an unknown value; a wallet may send a placeholder. Pick one convention. - 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"
};
}
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)
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?
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.linesreturns the zone exactly as read, so the issuing-state code (D<<for Germany,GBDand the other British codes) sits on line 1, positions 3–5, untouched.document.issuing_stateis 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 thatGBDcomes through asGBD, 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.statussays 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.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_stateandnationalitymap Germany'sDtoDEU. Every other code comes back as it did before, so nothing breaks for existing callers.issuing_state_detailandnationality_detail, give{ code, alpha3, alpha2, category }.codeis what the document prints, read from the MRZ itself.alpha3andalpha2are the ISO 3166-1 codes, or null where ISO has none.categorysays 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) orunknown.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