Implementing the eu.europa.ec.av.1 blueprint profile: five concrete traps on the mdoc path

By Olivier Meunier — independent developer building Tessaliq, an EUDI Wallet verifier focused on age verification under the French ARCOM framework.


TL;DR

The EU Digital Identity Wallet blueprint defines an Age Verification profile, eu.europa.ec.av.1, that specifies how a verifier and a wallet should exchange a privacy-preserving “is this user over N years old?” check on top of an mdoc credential (ISO 18013-5). The profile reads cleanly on paper. Implementing it on a real verifier surfaces a handful of subtle mistakes that the spec lets you make and that no test fixture catches. This post lists the five I hit while shipping the mdoc attribute-check path on Tessaliq, with the fix and a one-liner takeaway for each. If you are building a verifier against the same profile right now, this is the post I wish I had read three weeks before I started.

The post does not re-explain the blueprint or the mdoc format from scratch. If you need a primer, the EUDI Wallet ARF v2.8.0 and ISO/IEC 18013-5:2021 are the right starting points.


1. The trust store is not a list, it is a chain validator

The first time I wired the mdoc verifier to France Identité’s IACA (Issuing Authority Certificate Authority), I treated the trust store as a flat list of certificates: “if the leaf cert in the MSO chains to any of these, accept.” That works for the happy path. It silently breaks the moment an issuer rotates an intermediate.

Fix. Treat the trust store as a set of root IACA certs and run a real X.509 chain validator (in TypeScript, @peculiar/x509 does the job). Walk the x5c array from the MSO, build the candidate chain, validate each link’s signature, check notBefore/notAfter, check Basic Constraints, check Key Usage, then verify that the topmost cert’s issuer matches an IACA root in your store.

Takeaway. “Trust this issuer” is never just “match this cert.” It is “validate this chain against a root.”

2. The MSO validityInfo window is normative — do not skip it

The MSO carries a validityInfo map with signed, validFrom, and validUntil. ISO 18013-5 makes them normative: a verifier MUST reject any DeviceResponse whose MSO validUntil is in the past, and SHOULD reject one whose signed is far in the future relative to its own clock. The eu.europa.ec.av.1 blueprint inherits this requirement without relaxing it.

The trap: my first implementation parsed validityInfo for logging but did not use it as a gate. Result: an expired mdoc still produced an accepted age check, because the issuer signature was still valid. The MSO is not the credential — it is a snapshot of the credential’s trustworthiness at signing time, and it expires.

Fix.

const now = new Date()
const { validFrom, validUntil } = mso.validityInfo
if (now < validFrom) return reject('mso_not_yet_valid')
if (now > validUntil) return reject('mso_expired')

Allow a small clock-skew window (≤ 5 minutes) on validFrom only.

Takeaway. The MSO has a TTL. Enforce it before you look at any claim.

3. The age_over_NN element is namespaced, and the namespace matters

The blueprint profile reuses the ISO 18013-5 age_over_NN naming convention (age_over_18, age_over_21, etc.), but defines its own namespace: per Annex A §A.4, all attributes for the EU AV profile belong to namespace eu.europa.ec.av.1 — the same string as the docType. Where it gets subtle: a non-EU mdoc issuer (e.g. US states implementing AAMVA’s mDL profile) carries age_over_NN claims under org.iso.18013.5.1 (mDL) or org.iso.18013.5.1.aamva, with potentially different semantics. If your verifier blindly walks the issuer-signed namespaces and accepts the first matching key, you will conflate ecosystems and leak a trust assumption.

Fix. Hard-bind the verifier to eu.europa.ec.av.1 for the EU AV blueprint flow, and reject (or at minimum flag) any presentation that carries age_over_NN only under a namespace your trust store does not recognize for the policy at hand. Make the namespace check part of the same gate as the chain validation.

Takeaway. mdoc namespaces are not display labels. They are part of the credential identity.

4. Selective disclosure on age_over_NN returns a boolean, not the date of birth

This is the single most useful property of the profile, and the one easiest to throw away by accident. Under selective disclosure (mdoc-style or dc+sd-jwt-style), the wallet has the option to return either the age_over_18 element (a boolean: true / false) or the birth_date element (the full date). The verifier must request only the booleans it actually needs.

If your DCQL query (or your presentation_definition if you are still on draft-23) asks for birth_date because that is what your test wallet returns by default, you get the full date of birth back. Now the eu.europa.ec.av.1 privacy claim is dead in the water: you have stored a date of birth in your verifier’s logs, and the blueprint promise (the verifier learns only the boolean) is broken on your side.

Fix. Build the DCQL query against age_over_18 exclusively. Test with a fixture wallet that refuses to return birth_date. If your DCQL parser silently widens the query to include any matching element, that is a bug — fix it.

const query = {
  credentials: [{
    id: 'av',
    format: 'mso_mdoc',
    meta: { doctype_value: 'eu.europa.ec.av.1' },
    claims: [{ path: ['eu.europa.ec.av.1', 'age_over_18'] }],
  }],
}

Takeaway. Privacy is a property of the request, not the response. Ask only for what you need.

5. The session transcript binds the response to the verifier — and it has to match exactly

This one is a generalisation of a trap I described in a separate post about HPKE. The session transcript ties a DeviceResponse to a specific request, a specific verifier ephemeral key, and a specific origin. For OID4VP over the W3C Digital Credentials API, the transcript is a CBOR structure containing DeviceEngagementBytes (null), EReaderKeyBytes (the verifier’s public key as a tagged COSE_Key), and a Handover structure with the request origin, the nonce, and the JWK thumbprint of the verifier’s ephemeral encryption key.

If even one byte of CBOR diverges between the wallet’s serialisation and yours — wrong tag, wrong map key order, a 32-bit integer where a 16-bit one was expected — the device signature verification fails with an opaque error, and you spend an afternoon thinking your crypto is broken when in fact your encoder is.

Fix. Pin a CBOR encoder that is deterministic by construction (e.g. cbor2 with useStringKeys: false and canonical map ordering enabled) and write a unit test that round-trips a fixture transcript byte-for-byte. Log the hex of your transcript on both ends in dev. Compare.

Note on OpenID4VP 1.1. PR openid/OpenID4VP#703 is introducing an alternative ASCII session_info structure (not CBOR) as the HPKE info parameter for VP 1.1 when using JOSE HPKE — with open questions on JWK-hash inclusion. It does not replace the ISO 18013-7 CBOR SessionTranscript path described above; the two paths coexist and target different encryption mechanisms. If you build against VP 1.0 Final today, the CBOR path is what you ship.

Takeaway. CBOR in cryptographic contexts is normative bytes, not a serialisation library default. Any non-canonical encoder is a footgun.

A quick word on what the profile does not solve

The eu.europa.ec.av.1 profile gives you a privacy-preserving “over 18” boolean from a wallet that holds a real, government-issued mdoc. It does not solve:

  • Wallet pen-rate. A blueprint-compliant verifier still needs a fallback for the (very large) population that has not yet enrolled in an EUDI wallet. That fallback — facial age estimation or any other method — is a composition concern at the integrating site level, not a feature of the verifier itself. A pure EUDI verifier like Tessaliq returns wallet_unavailable when the user has no wallet and leaves the retry/fallback choice to the site.
  • Cryptographic non-correlation. Two attribute-check responses from the same wallet are linkable by signature on the device key. ZKP-based schemes (Longfellow, Noir) close this gap; the mdoc attribute check does not.
  • Issuer revocation. A revoked mdoc may still validate against an unrevoked MSO if you do not also fetch and verify a Token Status List (draft-ietf-oauth-status-list-20). The profile points at status lists; it does not enforce their use.

Those are real limits. They do not, however, prevent you from shipping a useful, conformant verifier today — they bound what you can claim about it.

Closing

The eu.europa.ec.av.1 profile is, in my opinion, the single most under-implemented spec in the EUDI stack right now: clean enough to build against, mature enough to ship, and not yet crowded by competing implementations. If you are an implementer hitting traps that are not in this list, please open an issue against tessaliq-open or send a note to contact [at] tessaliq [dot] com. The more notes get compared in public, the faster the gap between the spec and the deployed reality closes.


About the author

Olivier Meunier is an independent developer based in Angers, France. Tessaliq is a privacy-preserving identity verifier reference implementation he has been building on a personal basis. It is a pre-incorporation project. Open-core: the verifier core is private, while the Web SDK, the Noir circuits, the SD-JWT helper library and the shared utilities are MIT-licensed at github.com/Tessaliq/tessaliq-open. Production verification path today: mdoc attribute check aligned with eu.europa.ec.av.1. Contact: contact [at] tessaliq [dot] com.


← All posts