European Commission President Announces That EU Age Verification Is Available

On April 15, European Commission President Ursula von der Leyen announced that the EU age-verification app was available.

It is for parents to raise their children. Not platforms.

The European Age Verification App is ready ↓ https://t.co/EumEPEJOI7

— Ursula von der Leyen (@vonderleyen) April 15, 2026

Soon afterward, however, reports like the following began appearing, claiming that it could be “hacked in 2 minutes.”

Attack with a Cooperating Party

The 1st is an attack carried out by the person concerned with a cooperating party. The key points are:

  1. Once an age-verification credential has been issued, it can be used an unlimited number of times.
  2. No PIN or biometric information is specifically required to use it.
  3. (There is also information suggesting that this credential is bound neither to the hardware nor to the App Instance and can be moved to another smartphone… Well, in an attack involving a cooperating party, that party could simply have the credential issued on the attacker’s smartphone, so this point itself is not particularly important.)

Thus, with the cooperation of someone aged 18 or older, an attacker under the age of 18 can have a credential stating that they are aged 18 or older issued and then use it without limit. This requires measures such as rooting the phone, but because the person concerned is doing it, that is feasible. From a threat-modeling perspective, the question is therefore what to do under the assumption that neither the person, the phone, nor the wallet-app instance can be trusted. The version released this time appears to have chosen not to address that point, for reasons including the principle of proportionality.

Hacking the #EU #AgeVerification app in under 2 minutes.

During setup, the app asks you to create a PIN. After entry, the app *encrypts* it and saves it in the shared_prefs directory.

1. It shouldn’t be encrypted at all – that’s a really poor design.
2. It’s not… https://t.co/z39qBdclC2 pic.twitter.com/FGRvWtWzaZ

.@vonderleyen "The European #AgeVerification app is technically ready. It respects the highest privacy standards in the world. It's open-source, so anyone can check the code…"

I did. It didn't take long to find what looks like a serious #privacy issue.

The app goes to great lengths to protect the AV data AFTER collection (is_over_18: true is AES-GCM'd); it does so pretty well.

But, the source image used to collect that data is written to disk without encryption and not deleted correctly.

For NFC biometric data:
It pulls DG2 and writes a lossless PNG to the filesystem. It's only deleted on success. If it fails for any reason (user clicks back, scan fails & retries, app crashes etc), the full biometric image remains on the device in cache. This is protected with CE keys at the Android level, but the app makes no attempt to encrypt/protect them.

For selfie pictures:
Different scenario. These images are written to external storage in lossless PNG format, but they're never deleted. Not a cache… long-term storage. These are protected with DE keys at the Android level, but again, the app makes no attempt to encrypt/protect them.

This is akin to taking a picture of your passport/government ID using the camera app and keeping it just in case. You can encrypt data taken from it until you're blue in the face… leaving the original image on disk is crazy & unnecessary.

From a #GDPR standpoint:
Biometric data collected is special category data. If there's no lawful basis to retain it after processing, that's potentially a material breach.
https://youtube.com/watch?v=4VRRriyDKKk

Paul Moore – Security Consultant  (@Paul_Reviews) 4月15日
— Paul Moore – Security Consultant  (@Paul_Reviews) April 16, 2026

Verification Implementation Error at the Verifier

Another report claimed that age verification could be bypassed at the verifier. But I am not sure what to make of this… The “issuer” being used is a sample issuer, and the “verifier” is also a sample. The flow is:

  1. Obtain an age-verification mdoc/sd-jwt from the sample issuer.
  2. Use it to log in to a sample site that requires age verification.

Please see the demonstration below.

Bypassing #EU #AgeVerification using their own infrastructure.

I’ve ported the Android app logic to a Chrome extension – stripping out the pesky step of handing over biometric data which they can leak… and pass verification instantly.

Step 1: Install the extension
Step 2:… https://t.co/9zSony8Em4 pic.twitter.com/a5oQnf0n2Y

Hacking the #EU #AgeVerification app in under 2 minutes.

During setup, the app asks you to create a PIN. After entry, the app *encrypts* it and saves it in the shared_prefs directory.

1. It shouldn't be encrypted at all – that's a really poor design.
2. It's not cryptographically tied to the vault which contains the identity data.

So, an attacker can simply remove the PinEnc/PinIV values from the shared_prefs file and restart the app.

After choosing a different PIN, the app presents credentials created under the old profile and let's the attacker present them as valid.

Other issues:
1. Rate limiting is an incrementing number in the same config file. Just reset it to 0 and keep trying.
2. "UseBiometricAuth" is a boolean, also in the same file. Set it to false and it just skips that step.

Seriously @vonderleyen – this product will be the catalyst for an enormous breach at some point. It's just a matter of time.

EU年齢確認アプリのリダイレクトQR
Paul Moore – Security Consultant  (@Paul_Reviews) 4月16日
— Paul Moore – Security Consultant  (@Paul_Reviews) April 16, 2026

That said, both this issuer and verifier appear merely to be demonstrations of how they operate when things succeed. It also appears that no identity-document check is required to obtain an mdoc/sd-jwt. As far as the publicly available code shows, the verifier does not perform proper verification either. Specifically, DocumentValidator.kt does appear to verify the signature and whether the issuer is on the trust list. Even if those checks fail, however, it returns a data structure called trust_info populated with information from the credential; if that structure contains an age_over_18 claim, it appears to treat the age verification as successful.

Still, this is only within a demo app. Of course, it would be unacceptable to build a production site by reusing this demo application’s code unchanged, but the reaction also feels somewhat excessive.

Nevertheless, anyone implementing this should be sure to:

  1. Properly verify signatures.
  2. Properly verify the trust chain up to a trusted issuer.
  3. Reflect the results in access management.

Please do not forget these points. They are also what I kept saying at the Digital Agency’s “Expert Panel on Organizing Issues Concerning Attribute Attestation”.

Also, implementing President von der Leyen’s statement that “it is parents, not platforms, who protect children” would require proof of the parent-child relationship. Age verification alone cannot provide that.

One more point: the age-verification app discussed here differs from the “age assurance framework” referred to in ISO/IEC 27566 and elsewhere. It corresponds to the “age verification” component within an “age assurance framework.”

I also had ChatGPT analyze the source code as of April 16, so I have included the results below as an appendix. I have not verified whether the content is correct. (I only looked briefly at the beginning.) Engineers, I would appreciate it if you could point out anything that seems wrong.

Appendix A. ChatGPT Review of the Source Code for OpenID4VP Processing

  1. Wallet posts the response to /wallet/direct_post.
    The repo docs identify /wallet/direct_post as the wallet-response endpoint. The backend path that processes that response is PostWalletResponseLive.invoke at PostWalletResponse.kt:223-233, which calls doInvoke(...) at 235-265.
  2. The response is submitted and each vp_token item is validated.
    In PostWalletResponse.kt:318-334, submit(...) converts the wallet payload with responseObject.toDomain(...). Inside AuthorisationResponseTO.verifiablePresentations(...), each VP element is passed to validateVerifiablePresentation(...).bind() at PostWalletResponse.kt:100-155, specifically 136-145.
  3. For mso_mdoc, the backend takes the MSO mdoc validator path and stores trust info.
    In ValidateSdJwtVcOrMsoMdocVerifiablePresentation.kt:92-101, the Format.MsoMdoc branch calls validator.validateMsoMdocVerifiablePresentation(...) and then addTrustInfo(transactionId, trustInfo). The trust-info store helpers are at 54-68.
  4. The backend does perform real chain and issuer-signature checks.
    In DocumentValidator.kt:80-105, ensureValidWithTrustInfo(document) runs the document validation sequence. The issuer signature check is ensureValidIssuerSignature(...) at 137-146. The chain-trust check is ensureValidChain(...) at 218-226. Trust metadata is assembled in buildTrustInfoFromResults(...) at 234-263.
  5. But trust/signature failure is downgraded to trust_info, not enforced as rejection.
    The critical code is DeviceResponseValidator.kt:95-125. The comment at 95-98 says the method “does not fail due to trust issues.” At 104-118, if documentValidator.ensureValidWithTrustInfo(document) returns Left, the code creates defaultTrust with issuerInTrustedList=false, issuerNotExpired=false, and signatureValid=false, then still returns DocumentWithTrust(document, defaultTrust). At 122-125, it returns a successful DocumentValidationResult.
  6. The presentation validator then accepts the VP anyway unless issuerAuth is missing.
    In ValidateSdJwtVcOrMsoMdocVerifiablePresentation.kt:159-182, validateMsoMdocVerifiablePresentation(...) calls ensureValidWithTrustInfo(...) at 166-171, extracts documents and trustInfos at 173-174, and then only enforces that document.issuerSigned.issuerAuth is present at 176-179. It does not require signatureValid, issuerInTrustedList, or isFullyTrusted to be true before returning success at 182.
  7. Because of that, the wallet response is stored and the transaction moves to Submitted state.
    Back in PostWalletResponse.kt, submit(...) returns a Submitted presentation at 318-334, and doInvoke(...) stores it at 249-252. So the verifier backend accepts and stores the wallet response even when trust/signature failed in the permissive mdoc path above.
  8. When the verifier UI polls /ui/presentations/{transactionId}, the backend attaches trust_info to the response.
    The repo docs identify GET /ui/presentations/{transactionId} as the verifier’s wallet-response endpoint. In GetWalletResponse.kt:119-132, found(...) gets the stored trust info with ValidateSdJwtVcOrMsoMdocVerifiablePresentation.getTrustInfo(...), copies it into the returned wallet response, then clears the store.
  9. The frontend polls that endpoint and receives vp_token plus optional trust_info.
    In presentation.ts:68-114, GetPresentationState(transactionID) fetches GET /ui/presentations/${transactionID}.
  10. The frontend sets trust_info, but independently decodes proof_of_age and uses its attributes as the success source.
    In App.tsx:178-191, if data.trust_info exists it is stored, but the code then decodes data.vp_token.proof_of_age and sets verifiedData from firstAttestation.attributes. Then at App.tsx:211-221, isAgeOver18 is computed only from whether verifiedData contains age_over_18=true.
  11. The success message is driven by verifiedData, while trust is rendered separately.
    verification-texts.tsx:19-25 shows “You have successfully proven your age” purely from the eu.europa.ec.av.1:age_over_18 value. Separately, App.tsx:246-253 renders TrustInfoDisplay only as an additional component. In trust-info.tsx:78-145, that component shows a scorecard; it does not gate the success message.

Related posts