DEV Community

Cover image for Validating an EU Proof-of-Age Attestation (eu.europa.ec.av.1) Against the Trusted List
eidas-pro for eidas-pro

Posted on • Originally published at eidas-pro.com

Validating an EU Proof-of-Age Attestation (eu.europa.ec.av.1) Against the Trusted List

The EU age-verification app is shipping (feature-ready 15 April 2026, open-source reference at ageverification.dev). If you're building the verifier side — the online service that consumes the proof — two things trip everyone up: the namespace eu.europa.ec.av.1, and the requirement to validate the attestation against the trusted list. This is the part that actually makes the age check trustworthy, and it's the part the marketing pages skip. Let's go through it properly.

1. The credential on the wire

The app issues a Proof of Age Attestation in ISO/IEC 18013-5 mso_mdoc (CBOR) form. Its docType and its only namespace are both eu.europa.ec.av.1, and it carries a minimal, fixed attribute set:

Attribute Type
age_over_18 boolean
age_over_21 boolean
age_in_years integer
age_birth_year integer
expiry_date string

The spec is explicit: a Proof of Age Attestation SHALL NOT include any other attribute. No name, no document number, no date of birth. It's an electronic attestation of attributes (Art. 3(44) EUDI Regulation) that asserts a threshold and nothing else.

2. Why the signature alone proves nothing

Your verifier gets a signed MSO asserting age_over_18: true. Verifying that signature tells you the bytes weren't modified. It does not tell you the signer is allowed to issue age attestations — anybody can mint a key and sign a boolean.

The trust decision is therefore:

Does the certificate that signed this attestation appear on the EU's list of authorised Proof of Age Attestation Providers?

That list is the Age Verification Trusted List.

3. The AV Trusted List format

It's an ETSI-style trusted list hosted by the Commission in the eIDAS Dashboard. Member States notify the providers established in their territory; the list publishes each provider's signing certificate. The identifiers you match on:

<TSLType>http://ec.europa.eu/tools/lotl/av/TrstSvc/TrustedList/TSLType/AVTL</TSLType>
...
<TSPService>
  <ServiceInformation>
    <ServiceTypeIdentifier>http://ec.europa.eu/tools/lotl/av/TrstSvc/Svctype/PAA</ServiceTypeIdentifier>
    <ServiceDigitalIdentity>
      <DigitalId><X509Certificate>MIIB...==</X509Certificate></DigitalId>
    </ServiceDigitalIdentity>
  </ServiceInformation>
</TSPService>
Enter fullscreen mode Exit fullscreen mode

AVTL = the Age Verification Trusted List type; PAA = a Proof of Age Attestation Provider service; the X509Certificate is your trust anchor.

The reference verifier backend ships an example list and wires it up with a namespace pattern:

# src/main/resources/application.properties
verifier.trustSources[0].pattern=eu.europa.ec.av.*
verifier.trustSources[0].tl.location=classpath:av-etsi-trusted-list.xml
Enter fullscreen mode Exit fullscreen mode

The example av-etsi-trusted-list.xml lives in the reference repo (eu-digital-identity-wallet/av-srv-web-verifier-endpoint-23220-4-kt) and includes the verifier.ageverification.dev test issuer.

4. The validation algorithm

Conceptually, per presented attestation:

async function validateProofOfAge(mdoc: Mdoc, trustedList: AvTrustedList) {
  // 1. Scope: right docType, read only the claim you requested
  assert(mdoc.docType === "eu.europa.ec.av.1");
  const over18 = mdoc.getClaim("eu.europa.ec.av.1", "age_over_18");

  // 2. Authenticity: verify the MSO issuer signature
  const dsCert = mdoc.issuerAuth.documentSignerCert;
  assert(verifyMsoSignature(mdoc.issuerAuth, dsCert));

  // 3. Trust: the signer must chain to a PAA cert on the AV Trusted List
  const anchors = trustedList
    .services(ServiceType.PAA) // http://ec.europa.eu/tools/lotl/av/TrstSvc/Svctype/PAA
    .flatMap((s) => s.x509Certificates);
  assert(chainsToTrustedAnchor(dsCert, anchors)); // <-- the step that creates trust

  // 4. Validity window
  assert(within(mdoc.validityInfo) && notExpired(mdoc.expiry_date));

  // 5. Holder/device binding: wallet proves control of the bound key
  assert(await verifyDeviceBinding(mdoc.deviceAuth));

  return over18 === true; // fail-closed everywhere above
}
Enter fullscreen mode Exit fullscreen mode

Skip step 3 and you have an age gate any attacker forges in five minutes. Hard-code one issuer cert and you break when the next Member State notifies a provider — which is exactly why the trust source is a list you refresh, not a constant.

5. Transports and proof types

Transport: support both. DC API (Digital Credentials API) is the browser-integrated primary path; OID4VP is the fallback. Select best-available at runtime.

Proof type: both are engineered against cross-service tracking.

  • ZKP — proves the threshold with no linkable identifier disclosed.
  • Standard mdoc — single-use, issued in batches (≈30); issuers coarsen the ValidityInfo timestamps (identical hh:mm:ss across the batch, per ISO 18013-5) so the attestation carries no correlatable fingerprint.

So even with plain mdoc, a compliant flow won't hand you a tracking handle. (Device binding uses a key in a WSCD; the ZKP variant proves knowledge of a valid signature over that key without revealing the signature or public key — that's the unlinkability upgrade the ARF is still standardising, so treat the ZKP path as maturing rather than universally available.)

6. Testing before go-live

  • Point your verifier at the acceptance trusted list (acceptance.eidas.ec.europa.eu) — one shared list with multiple national test issuers.
  • The ageverification.dev test issuer is listed there under Iceland.
  • Or run fully local with the reference backend's bundled av-etsi-trusted-list.xml.

7. Gotchas checklist

  • ✅ Validate the signer against the trusted list — not just the signature.
  • ✅ Treat the trusted list as refreshable (new MS providers land over time); handle revocation.
  • Fail closed on any failure — never allow-on-error.
  • ✅ Enforce single-use: don't accept the same batched mdoc twice.
  • ✅ Read only the claim you need; the namespace is minimal by design — keep it that way downstream.
  • ✅ Support DC API and OID4VP; browser/wallet coverage is still evolving.

Getting this validation right is the entire job of an age verifier — everything else is UX. If you'd rather consume a clean boolean than own ETSI trusted-list parsing, certificate-chain validation, and revocation, that's the layer we run at eIDAS Proeu.europa.ec.av.1 in, boolean out, no personal data stored.

Work from the current EU Age Verification specifications; this is technical information, not legal advice.

Top comments (0)