The European Digital Identity wallet is coming, and applications accepting it will need to talk to it. This is the second of two posts showing how to do that with pac4j: in the first post the wallet ran on the same phone as the browser; here the browser is on a computer, the wallet on a phone, and a QR code bridges the two.
1) Why prepare for EUDI wallets?
Regulation (EU) 2024/1183, better known as eIDAS 2.0, entered into force on May 20, 2024. EU member states must offer EUDI wallets by the end of 2026, holding person identification data (PID) and attestations such as driving licences or diplomas. Using a wallet remains voluntary.
Article 5f also requires acceptance by public services requiring electronic identification, certain private services requiring strong authentication, and very large online platforms requiring authentication. Private-sector obligations have conditions and exemptions, including for microenterprises and small enterprises, with a deadline tied to implementing acts, generally described as late 2027.
For applications in scope, wallet authentication is becoming an integration requirement worth preparing for.
2) Why it is hard
A wallet login looks simple: the user opens their wallet, sees what your application asks for, consents, and the wallet sends a signed proof. Behind this, the stack is anything but simple. Your application, the verifier in the vocabulary of the specifications, has to deal with:
- OpenID for Verifiable Presentations 1.0, the protocol itself: how the wallet is invoked, how it fetches the request, how it posts the response, with its Digital Credentials Query Language (DCQL) to say what you want
- the High Assurance Interoperability Profile (HAIP), which pins the options the EUDI wallet uses, and the Architecture and Reference Framework (ARF) of the European Commission, with its PID rulebook
- two credential formats: the SD-JWT VC of the IETF, built on selective disclosure JWTs, and the mobile documents (mdoc) of ISO/IEC 18013-5, encoded in CBOR and signed with COSE
- the plumbing underneath: signed request objects (RFC 9101), JWS, JWE and JWK, X.509 certificate chains and revocation, decentralized identifiers, and the Digital Credentials API of the browsers for the newest flows.
And several ways to identify the verifier, several ways to reach the wallet, several response modes, each with its own validation rules. Implementing this by hand, and keeping it right as the specifications move, is a project in itself.
3) pac4j to the rescue
pac4j has one security model for every protocol: a Client starts the authentication, a callback receives the answer, a UserProfile ends up in the session. CAS, SAML, OpenID Connect, OAuth all fit in it, and a digital wallet does too.
The new pac4j-openid4vp module, which arrives with pac4j v6.6.0, adds the EudiWalletClient: you give it a DCQL query, a keystore and the issuers you trust, and it handles the signed request, the encrypted response, credential verification and the user profile. If you have already used pac4j, the integration will look familiar.
The first post walked through a same-device flow with an SD-JWT VC PID. This one takes the wallet to another device: the user is on a computer, the page shows a QR code, the phone scans it, and an mdoc PID comes back, the format of ISO/IEC 18013-5, the one of the mobile driving licences.
4) A young protocol: test with demo wallets
OpenID4VP 1.0 was finalized in 2025, and wallet implementations are still evolving. Demo wallets let you test the integration while preparing for national EUDI deployments; expect differences between wallets and versions.
A very good one is the Paradym wallet by Animo, available for Android and iOS, and its playground which plays both the issuer and the verifier:
- install the Paradym wallet on your phone
- open playground.paradym.id on your computer and, on the Issuance page, choose PID mDoc, the card this post uses: the page shows a QR code, scan it with the Paradym wallet, accept the offer, and the card appears in the wallet
- keep the Trust Information page of the playground at hand: it shows the certificate of the playground issuer, which your application must trust.
The cards are Emma Charlotte van der Berg's, a fictitious Dutch citizen, and the mdoc PID carries, among other attributes, a personal_administrative_number: a stable identifier, which the profile below relies on.
Issue the card from this playground and no other: a PID issued elsewhere, by the older playground.animo.id for instance, is signed by another certificate and the truststore of section 7 refuses it. If the wallet already holds PID cards from another source, delete them, or at least pick the right one when the wallet asks which card to present.
5) ngrok: a real URL for the wallet
The wallet talks to your application over HTTPS, from the phone: it fetches the request object and posts its response. The phone is not on your computer, so localhost will not do.
The simplest way during development is ngrok, which exposes your local port on a public HTTPS address:
ngrok http 8080 It prints a public URL such as https://abcd-1234.ngrok-free.dev: this is the base URL of your application for the rest of this post. The free plan includes a development domain assigned to your account. Copy the URL ngrok prints into the property below. If the browser shows an ngrok warning page, choose Visit Site to continue.
6) Create the Spring Boot project
This is a plain Spring Boot web application, with spring-webmvc-pac4j as the pac4j implementation. Java 17 or later and Maven are enough.
mkdir -p wallet-another-device/src/main/java/org/pac4j/demo/wallet mkdir -p wallet-another-device/src/main/resources wallet-another-device/metadata cd wallet-another-device The pom.xml. The mdoc verification relies on the walt.id library, which pac4j declares as optional and which walt.id publishes on its own repository:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.0.8</version> <relativePath/> </parent> <groupId>org.pac4j.demo</groupId> <artifactId>wallet-another-device</artifactId> <version>1.0-SNAPSHOT</version> <properties> <java.version>17</java.version> <pac4j.version>6.6.0</pac4j.version> <kotlin.version>2.4.21</kotlin.version> <kotlin-coroutines.version>1.11.0</kotlin-coroutines.version> <kotlin-serialization.version>1.11.1-waltid-20260811</kotlin-serialization.version> <jackson-2-bom.version>2.22.3</jackson-2-bom.version> <commons-lang3.version>3.21.0</commons-lang3.version> <slf4j.version>2.0.20</slf4j.version> <commons-logging.version>1.3.5</commons-logging.version> <jspecify.version>1.0.0</jspecify.version> </properties> <!-- walt.id publishes the mdoc library and Kotlin Serialization variant outside Maven Central --> <repositories> <repository> <id>waltid-releases</id> <url>https://maven.waltid.dev/releases</url> <snapshots><enabled>false</enabled></snapshots> </repository> </repositories> <dependencyManagement> <dependencies> <!-- align pac4j-core and shared dependencies with pac4j-openid4vp --> <dependency> <groupId>org.pac4j</groupId> <artifactId>pac4j-parent</artifactId> <version>${pac4j.version}</version> <type>pom</type> <scope>import</scope> </dependency> <!-- spring-webmvc-pac4j otherwise pulls an older Jakarta EE adapter --> <dependency> <groupId>org.pac4j</groupId> <artifactId>pac4j-jakartaee</artifactId> <version>${pac4j.version}</version> </dependency> <!-- retain walt.id's legacy datetime API using the compatibility artifact used by pac4j --> <dependency> <groupId>org.jetbrains.kotlinx</groupId> <artifactId>kotlinx-datetime-jvm</artifactId> <version>0.8.0-0.6.x-compat</version> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- the pac4j implementation for Spring MVC --> <dependency> <groupId>org.pac4j</groupId> <artifactId>spring-webmvc-pac4j</artifactId> <version>8.0.4</version> </dependency> <!-- the OpenID4VP support: the application is a verifier --> <dependency> <groupId>org.pac4j</groupId> <artifactId>pac4j-openid4vp</artifactId> <version>${pac4j.version}</version> </dependency> <!-- the mdoc verification, an optional library of pac4j --> <dependency> <groupId>id.walt.mdoc-credentials</groupId> <artifactId>waltid-mdoc-credentials-jvm</artifactId> <version>0.12.0</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project> The walt.id library is written in Kotlin. This POM matches pac4j's runtime versions: Kotlin 2.4.21, Coroutines 1.11.0, Serialization 1.11.1-waltid-20260811 and Datetime 0.8.0-0.6.x-compat. The Serialization variant comes from the walt.id repository; the Datetime compatibility artifact preserves the legacy API used by walt.id 0.12.0. Do not replace it with a version which removes that API. See Kotlin alignment, especially when verifying both SD-JWT VC and mdoc credentials.
The imported pac4j-parent dependency management aligns pac4j-core and shared libraries; the explicit pac4j-jakartaee entry aligns the adapter pulled in by spring-webmvc-pac4j with pac4j-openid4vp. Spring Boot 4.0.8 supplies Spring Framework 7.0.9, matching pac4j; the properties align Jackson 2, Commons Lang, SLF4J, Commons Logging and JSpecify too. Jackson 3 remains managed by Boot for its own web stack. Letting Boot select its older Kotlin dependencies can cause runtime linkage failures even if the demo compiles.
Check the resolved versions with mvn dependency:tree. While pac4j 6.6.0 is unreleased, install the current snapshot locally and run Maven with -Dpac4j.version=6.6.0-SNAPSHOT; keep all pac4j modules on the same version.
The src/main/resources/application.properties file holds the base URL and enables the logs we will read at the end:
app.base-url=https://abcd-1234.ngrok-free.dev logging.level.org.pac4j.openid4vp=DEBUG logging.level.PROTOCOL_MESSAGE.OPENID4VP=DEBUG And the usual Spring Boot entry point:
package org.pac4j.demo.wallet; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class WalletApplication { public static void main(final String[] args) { SpringApplication.run(WalletApplication.class, args); } } 7) Keys and trust
Two keystores go in the metadata directory, the same as in the first post. Run the keytool commands below from the root of the project, next to the pom.xml: the paths are relative to it, like the ones in the configuration.
The verifier key. EudiWalletClient signs requests and identifies the verifier by its certificate hash (x509_hash). Production EUDI deployments use a relying party access certificate issued by the ecosystem. This demo uses a self-signed certificate with an EC P-256 key, accepted by the tested Paradym wallet; self-signed verifier certificates are outside the HAIP requirements:
keytool -genkeypair -alias rp -keyalg EC -groupname secp256r1 -sigalg SHA256withECDSA \ -dname "CN=pac4j demo verifier,O=pac4j" -validity 365 \ -keystore metadata/verifier.p12 -storetype PKCS12 -storepass changeit The issuers you trust. No credential is accepted unless its issuer is trusted, and pac4j trusts nobody by default. For an mdoc, the trust anchor is the issuing authority certificate authority (IACA) of the PID provider. Copy the Issuer Certificate from the Trust Information page of the playground into a paradym-issuer.pem file, and import it into a truststore:
keytool -importcert -alias paradym -file paradym-issuer.pem -noprompt \ -keystore metadata/pid-issuers.p12 -storetype PKCS12 -storepass changeit 8) Configure pac4j
The configuration is a Pac4jSecurityConfig of spring-webmvc-pac4j: the Config bean defines the client, and the interceptors say which URLs it protects.
package org.pac4j.demo.wallet; import org.pac4j.core.config.Config; import org.pac4j.core.config.properties.KeystoreProperties; import org.pac4j.openid4vp.client.EudiWalletClient; import org.pac4j.openid4vp.config.OpenId4VpConfiguration; import org.pac4j.openid4vp.dcql.EudiPidQuery; import org.pac4j.openid4vp.verifier.MdocVerifier; import org.pac4j.openid4vp.verifier.trust.CertificateTrustedIssuer; import org.pac4j.springframework.config.Pac4jSecurityConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import java.util.Map; import static org.pac4j.core.profile.definition.CommonProfileDefinition.FAMILY_NAME; import static org.pac4j.openid4vp.profile.EudiPidProfileDefinition.GIVEN_NAME; import static org.pac4j.openid4vp.profile.EudiPidProfileDefinition.PERSONAL_ADMINISTRATIVE_NUMBER; import static org.pac4j.openid4vp.profile.EudiPidProfileDefinition.PID_DOCTYPE; @Configuration public class SecurityConfig extends Pac4jSecurityConfig { private static final Logger LOGGER = LoggerFactory.getLogger(SecurityConfig.class); @Value("${app.base-url}") private String baseUrl; @Bean public Config config() { // the request: signed with the key of the verifier, asking for three attributes of the PID as an mdoc final var configuration = new OpenId4VpConfiguration() .setKeystore(new KeystoreProperties("./metadata/verifier.p12") .setKeyStoreType("PKCS12") .setKeyStoreAlias("rp") .setKeystorePassword("changeit") .setPrivateKeyPassword("changeit")) .setDcqlQuery(EudiPidQuery.mdoc(GIVEN_NAME, FAMILY_NAME, PERSONAL_ADMINISTRATIVE_NUMBER)) // No redirection is needed at the end of the process as the wallet is not on the same device. // Therefore, setUseRedirectUri is set to false, although this is unnecessary as it is the default value. // This is for demonstration purposes only. .setUseRedirectUri(false); // the response: an mdoc is accepted if its certificate chain ends in the truststore final var issuers = new CertificateTrustedIssuer(new KeystoreProperties("./metadata/pid-issuers.p12") .setKeyStoreType("PKCS12") .setKeystorePassword("changeit")); // certificate revocation checking is disabled to simplify this demo; keep it enabled in production issuers.setCertificateRevocationEnabled(false); // an mdoc may reference a status list: this demo logs the reference and accepts the document, // a real status checker must retrieve the list, verify it and check the entry of the document configuration.addCredentialVerifier(new MdocVerifier().addTrustedIssuer(issuers) .setStatusChecker((credential, status) -> LOGGER.info("document status not checked: {}", status))); // the profile identifier: the issuer and the administrative number, read in the namespace of the PID configuration.setProfileIdResolver(credentials -> { final var pid = credentials.getVerifiedCredentials().get(EudiPidQuery.PID).get(0); final var namespace = (Map<?, ?>) pid.getClaims().get(PID_DOCTYPE); return pid.getIssuer() + "|" + namespace.get(PERSONAL_ADMINISTRATIVE_NUMBER); }); final var client = new EudiWalletClient(configuration); return new Config(baseUrl + "/callback", client); } @Override public void addInterceptors(final InterceptorRegistry registry) { addSecurity(registry, "EudiWalletClient").addPathPatterns("/protected/**"); } } Compared to the first post, three things change:
-
useRedirectUristaysfalse, its default. In the first post it istrue, so the wallet returns the phone's browser to the pac4j callback. Here the authenticated session belongs to the computer: redirecting the phone would not bring its session cookie along. The computer page polls for the response and continues to the callback itself, without aresponse_code. - The verifier is
MdocVerifier, which checks the issuer authentication of the document, its digests, its validity, the signature of the device and the binding of the response to the request, and the chain of the document signer up to your truststore. - The claims of an mdoc are grouped by namespace: for the PID, the namespace is its document type,
eu.europa.ec.eudi.pid.1. The profile identifier reads the administrative number in that namespace, and the profile exposes an attribute named after the namespace, holding the three claims.ProfileIdResolver.issuerAndClaim(...)only reads the top-level claims of an SD-JWT VC, hence the small resolver above.
The rest is the same: EudiWalletClient pins the HAIP choices, x509_hash and direct_post.jwt, and the callback URL serves both the wallet and the browser.
These settings do not constitute complete HAIP conformance validation. An mdoc may carry a status object in its MSO, a reference to an OAuth Token Status List: the list where the issuer marks the documents it has revoked or suspended. pac4j refuses a document carrying a status unless a status checker is configured, because only the application can decide how to read the list, and it never fetches it on its own. The mdoc PID of the playground carries none today, unlike its SD-JWT VC PID, but the checker stays configured so that a document with a status is logged rather than refused: it receives the status object as a map, only logs it and accepts the document, so revocation is not checked here. A real checker retrieves the status list token, verifies its signature with the same trust as the document and checks the entry at the index of the document: that deserves a post of its own. See credential status. Disabling certificate revocation does not bypass this check.
9) The DCQL query
The query says what your application wants. EudiPidQuery.mdoc(GIVEN_NAME, FAMILY_NAME, PERSONAL_ADMINISTRATIVE_NUMBER) builds it in one line for the PID; this is what the wallet receives:
{ "credentials": [ { "id": "pid", "format": "mso_mdoc", "meta": { "doctype_value": "eu.europa.ec.eudi.pid.1" }, "claims": [ { "path": ["eu.europa.ec.eudi.pid.1", "given_name"] }, { "path": ["eu.europa.ec.eudi.pid.1", "family_name"] }, { "path": ["eu.europa.ec.eudi.pid.1", "personal_administrative_number"] } ] } ] } One credential query, identified as pid, for a mobile document whose type is the EUDI PID, and three data elements, each addressed by its namespace and its name. The same can be written by hand, in JSON when it comes from a configuration file:
configuration.setDcqlQuery(""" {"credentials":[{"id":"pid","format":"mso_mdoc","meta":{"doctype_value":"eu.europa.ec.eudi.pid.1"}, "claims":[{"path":["eu.europa.ec.eudi.pid.1","given_name"]},{"path":["eu.europa.ec.eudi.pid.1","family_name"]}, {"path":["eu.europa.ec.eudi.pid.1","personal_administrative_number"]}]}]}"""); Or in Java, with access to expected values, alternatives (claim_sets, credential_sets) and trusted authorities:
configuration.setDcqlQuery(new DcqlQuery() .addCredential(new CredentialQuery("pid", CredentialFormat.MSO_MDOC) .setDoctypeValue("eu.europa.ec.eudi.pid.1") .addClaim("eu.europa.ec.eudi.pid.1", "given_name") .addClaim("eu.europa.ec.eudi.pid.1", "family_name") .addClaim("eu.europa.ec.eudi.pid.1", "personal_administrative_number"))); Whatever the wallet presents is checked against the query again by pac4j: the format, the document type, the namespaces and the elements. The constraints are not left to the wallet.
10) The pages, with the QR code
In this flow, the wallet is on the phone rather than the computer running the browser. The page gets the wallet URL as data and displays it as a QR code, then waits for the wallet's response. Three pieces do that:
- the protected page itself, as before
- a
/statusendpoint, which reports the transaction state for the browser session:PENDING,RECEIVEDorEXPIRED, fromclient.getPresentationStatus(...).RECEIVEDincludes a wallet error response; the callback then reports that error - the page with the QR code and its script, which polls the
/statusendpoint every second until the wallet has answered.
The three live in a single Spring MVC controller, WalletController, to create next to the two previous classes:
package org.pac4j.demo.wallet; import org.pac4j.core.config.Config; import org.pac4j.core.context.CallContext; import org.pac4j.core.context.WebContext; import org.pac4j.core.context.session.SessionStore; import org.pac4j.core.profile.ProfileManager; import org.pac4j.openid4vp.client.OpenId4VpClient; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.CacheControl; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.util.HtmlUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ResponseBody; import java.util.Locale; import java.util.Map; @Controller public class WalletController { @Autowired private Config config; @Autowired private WebContext webContext; @Autowired private SessionStore sessionStore; @Autowired private ProfileManager profileManager; @GetMapping("/protected/index") @ResponseBody public String protectedPage() { return "<h1>Protected page</h1><p>" + HtmlUtils.htmlEscape(profileManager.getProfiles().toString()) + "</p>" + "<p><a href='/logout'>Logout</a></p>"; } /** Whether the wallet answered the transaction of this browser session. */ @GetMapping("/status") public ResponseEntity<Map<String, String>> status() { final var client = (OpenId4VpClient) config.getClients().findClient("EudiWalletClient").orElseThrow(); final var status = client.getPresentationStatus(new CallContext(webContext, sessionStore)); return ResponseEntity.ok().cacheControl(CacheControl.noStore()) .body(Map.of("status", status.name().toLowerCase(Locale.ROOT))); } @GetMapping("/") @ResponseBody public String index() { return """ <h1>Log in with your wallet</h1> <p>Scan the QR code with your phone's wallet. This page continues automatically when the wallet responds.</p> <div id="qr"></div> <pre id="log"></pre> <script src="https://cdn.jsdelivr.net/npm/qrcode-generator@2.0.4/dist/qrcode.js"></script> <script> const log = m => document.getElementById('log').textContent += m + '\\n'; async function start() { // the same request a browser would make, but as an AJAX call: pac4j then answers 401, // with the wallet URL in the Location header, instead of redirecting the page const r = await fetch('/protected/index', {headers: {'X-Requested-With': 'XMLHttpRequest'}}); if (r.ok) { window.location.assign('/protected/index'); return; } const walletUrl = r.headers.get('Location'); if (r.status !== 401 || !walletUrl) { throw new Error('Could not start wallet auth...