The European Digital Identity wallet is coming, and applications accepting it will need to talk to it. This is the first of two posts showing how to do that with pac4j. Here, the wallet runs on the same phone as the browser; in the second post, it runs on a phone while the browser is on a computer.

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:

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.

This first post walks through a complete example on the same device: the user is on their phone, clicks a link, the wallet opens, and an SD-JWT VC PID comes back. The second post does the same on another device, with a QR code and an mdoc PID.

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:

  1. install the Paradym wallet on your phone
  2. open playground.paradym.id on your computer and, on the Issuance page, choose PID SD-JWT, the card this post uses (PID mDoc is the one of the second post): the page shows a QR code, scan it with the Paradym wallet, accept the offer, and the card appears in the wallet
  3. 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: given name, family name, birth date, address...

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. Although the browser and wallet run on the same phone, the application runs on your development computer. localhost on the phone points to the phone itself, so it cannot reach that application.

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-same-device/src/main/java/org/pac4j/demo/wallet mkdir -p wallet-same-device/src/main/resources wallet-same-device/metadata cd wallet-same-device 

The pom.xml:

<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-same-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> <!-- the Kotlin Serialization variant used by pac4j is published by walt.id --> <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> </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 SD-JWT VC verification, an optional library of pac4j --> <dependency> <groupId>eu.europa.ec.eudi</groupId> <artifactId>eudi-lib-jvm-sdjwt-kt</artifactId> <version>0.20.2</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project> 

SD-JWT VC verification relies on the EUDI SD-JWT library, which pac4j declares as optional. This example uses version 0.20.2, matching pac4j. The imported pac4j-parent dependency management aligns pac4j-core and shared libraries. The explicit management entry for pac4j-jakartaee also aligns the adapter pulled in by spring-webmvc-pac4j; setting a pac4j.version property alone would not override that adapter's transitive version.

The runtime versions match pac4j's build: Kotlin 2.4.21, Coroutines 1.11.0 and Serialization 1.11.1-waltid-20260811. The Serialization variant is published in the walt.id repository, required here even though this demo does not use mdoc. Spring Boot 4.0.8 supplies Spring Framework 7.0.9, matching pac4j; the properties also align Jackson 2, Commons Lang, SLF4J, Commons Logging and JSpecify. Jackson 3 remains managed by Boot for its own web stack.

Boot's default Kotlin dependencies would otherwise override pac4j's versions. In particular, Coroutines 1.10.2 lacks BuildersKt.runBlockingK$default, called by the current pac4j adapter, and causes a NoSuchMethodError when the wallet posts its response.

To check the resolved versions, run mvn dependency:tree. For this unreleased pac4j version, 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. 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. 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.core.http.callback.PathParameterCallbackUrlResolver; import org.pac4j.openid4vp.client.EudiWalletClient; import org.pac4j.openid4vp.config.OpenId4VpConfiguration; import org.pac4j.openid4vp.dcql.EudiPidQuery; import org.pac4j.openid4vp.verifier.SdJwtVcVerifier; 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.UUID; import static org.pac4j.core.profile.definition.CommonProfileDefinition.FAMILY_NAME; import static org.pac4j.openid4vp.profile.EudiPidProfileDefinition.GIVEN_NAME; @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 two attributes of the PID as an SD-JWT VC final var configuration = new OpenId4VpConfiguration() .setKeystore(new KeystoreProperties("./metadata/verifier.p12") .setKeyStoreType("PKCS12") .setKeyStoreAlias("rp") .setKeystorePassword("changeit") .setPrivateKeyPassword("changeit")) .setDcqlQuery(EudiPidQuery.sdJwtVc(GIVEN_NAME, FAMILY_NAME)) // Mandatory when using the same browser .setUseRedirectUri(true); // the response: an SD-JWT VC 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); // the playground PID references a status list: this demo logs the reference and accepts the credential, // a real status checker must retrieve the list, verify it and check the entry of the credential configuration.addCredentialVerifier(new SdJwtVcVerifier().addTrustedIssuer(issuers) .setStatusChecker(credential -> LOGGER.info("credential status not checked: {}", credential.getClaims().get("status")))); // the demo PID carries no stable identifier: a new profile identifier at each login configuration.setProfileIdResolver(credentials -> UUID.randomUUID().toString()); final var client = new EudiWalletClient(configuration); // keep the client name in the callback path to work around URL decoding in the tested wallet client.setCallbackUrlResolver(new PathParameterCallbackUrlResolver()); return new Config(baseUrl + "/callback", client); } @Override public void addInterceptors(final InterceptorRegistry registry) { addSecurity(registry, "EudiWalletClient").addPathPatterns("/protected/**"); } } 

A few configuration details:

  • EudiWalletClient pins the HAIP choices: the request is signed and identifies the verifier by x509_hash, the hash of the certificate found in the keystore, and the response comes back encrypted (direct_post.jwt). These settings are applied by the client; they do not constitute complete HAIP conformance validation.
  • SdJwtVcVerifier verifies the expected format. With this trust configuration, the wallet's x5c certificate chain must lead to a certificate in your truststore, in addition to passing the signature, validity, disclosure and holder-binding checks.
  • The profile identifier is yours to choose: EudiWalletClient has no default and refuses to initialize until you configure a resolver. If the PID provider issues a stable, unique and non-reassigned personal_administrative_number, request it in DCQL and use ProfileIdResolver.issuerAndClaim(PERSONAL_ADMINISTRATIVE_NUMBER). That claim is not guaranteed for every PID, and this demo does not ask for it, hence the random identifier: it identifies a login, not a persistent user account.
  • The callback URL is the regular pac4j one, and the wallet uses it too: it fetches the request object there and posts its response there, without any browser session.
  • setUseRedirectUri(true) enables the automatic return to the browser. After accepting the wallet response, pac4j returns its callback URL as redirect_uri, with a fresh response_code. The wallet opens that URL in the browser; pac4j checks the code against the transaction of the browser session, saves the profile and redirects to the protected page originally requested. The option defaults to false; this same-device example enables it explicitly.

The playground PID carries a status claim, a reference to an OAuth Token Status List: the list where the issuer marks the credentials it has revoked or suspended. pac4j refuses a credential 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 checker of this demo only logs the reference and accepts the credential, so revocation is not checked here. A real checker retrieves the status list token, verifies its signature with the same trust as the credential and checks the entry at the index of the credential: 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.sdJwtVc(GIVEN_NAME, FAMILY_NAME) builds it in one line for the PID; this is what the wallet receives:

{ "credentials": [ { "id": "pid", "format": "dc+sd-jwt", "meta": { "vct_values": ["urn:eudi:pid:1"] }, "claims": [ { "path": ["given_name"] }, { "path": ["family_name"] } ] } ] } 

One credential query, identified as pid, for an SD-JWT VC whose type is the EUDI PID, and the two claims to disclose. The same can be written by hand, in JSON when it comes from a configuration file:

configuration.setDcqlQuery(""" {"credentials":[{"id":"pid","format":"dc+sd-jwt","meta":{"vct_values":["urn:eudi:pid:1"]}, "claims":[{"path":["given_name"]},{"path":["family_name"]}]}]}"""); 

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.SD_JWT_VC) .setVctValues("urn:eudi:pid:1") .addClaim("given_name") .addClaim("family_name"))); 

Whatever the wallet presents is checked against the query again by pac4j: the format, the type, the claims. The constraints are not left to the wallet.

10) The pages

Two pages are enough: a public home page, and a protected page which shows the profile. Both come from a single Spring MVC controller, WalletController, to create next to the two previous classes:

package org.pac4j.demo.wallet; import org.pac4j.core.profile.ProfileManager; import org.springframework.beans.factory.annotation.Autowired; 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; @Controller public class WalletController { @Autowired private ProfileManager profileManager; @GetMapping("/") @ResponseBody public String index() { return "<h1>Home</h1>" + "<p>Start in your phone's default browser, using its regular profile (not private browsing). " + "The wallet must return to the same browser and profile to complete authentication.</p>" + "<p><a href='/protected/index'>Open the protected page: the wallet opens</a></p>"; } @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>"; } } 

The home page needs only the link which starts authentication. The callback is handled by pac4j: with setUseRedirectUri(true), the wallet returns the browser there automatically, then pac4j restores the original request. No second link, polling script or application code relaying the response_code is needed.

Run the application, with ngrok running next to it:

mvn spring-boot:run 

11) The flow, step by step

Everything happens on the phone. First open the Paradym wallet and unlock it, then leave it in the background: the flow fails if the wallet is not already running when the browser hands it the openid4vp:// link. Then open the ngrok URL in the phone's default browser, using its regular profile (not private browsing) and click the link on the home page: Open the protected page: the wallet opens.

The wallet may open the return URL in the phone's default browser. Starting in another browser, a private browsing session or an embedded browser can therefore lose the original session: the callback cannot complete authentication and the demo returns to the home page. Use the same browser and profile throughout the flow.

The six screens below show the home page where login starts, the request to review, the warning about the unverified verifier, the PIN code, the wallet success screen and the return to the protected page in the browser, where the profile is displayed. The steps below follow the exchange through to that final page.

The application home page: open the protected page to start the wallet login

Paradym: review the request

Paradym: organization not verified

Paradym: enter the PIN code

Paradym: success, the information has been shared

Back in the application: the protected page displays Emma Charlotte van der Berg's profile after the automatic wallet return

1. The browser asks for the protected page. pac4j finds no profile, creates a transaction and redirects to an openid4vp:// URL. The browser hands this scheme to the operating system, which opens the wallet.

2. The wallet fetches the request. The URL only carries the identifier of the verifier and a request_uri: the wallet calls it, on the callback URL of your application, and receives the signed request object, with the DCQL query and the public key to encrypt its response.

3. The user consents. The wallet shows who is asking, the hash of your certificate for now, and what is asked: the PID, given name and family name. The user picks the card and confirms with their PIN.

4. The wallet posts its response. An encrypted JWT, containing the SD-JWT VC with the two disclosures and a key binding JWT proving the wallet holds the key of the credential, posted on the callback URL. pac4j decrypts, verifies and keeps the result for the browser session which started the transaction. Its HTTP 200 JSON response contains a redirect_uri pointing to /callback/EudiWalletClient?response_code=.... This is the browser return URL, not the response_uri where the wallet just posted the presentation.

5. The browser returns automatically. The wallet opens the redirect_uri in the default browser. Because the flow started in that browser and profile, its session cookie accompanies the callback. pac4j checks the response_code against that session's pending transaction, retrieves the profile already validated at step 4, consumes the transaction and stores the profile in the session. It then redirects to /protected/index, the URL saved at step 1: the protected page shows Emma Charlotte van der Berg. The code is handled entirely by pac4j and is not carried to the final protected URL.

The response_code is a fresh random value required by OpenID4VP section 8.2. The callback requires both this code and the session which started the transaction; a missing or wrong code is refused, another session cannot claim the presentation, and a completed transaction cannot be consumed again. Keep the return in the original browser: a different browser or an isolated webview does not have its session cookie. The second post uses polling instead, because the wallet is on the phone while the browser waiting for the answer is on the computer.

12) What the logs say

The two loggers enabled in application.properties give two views of the flow. The PROTOCOL_MESSAGE.OPENID4VP logger prints the messages themselves, as they were sent or received, without any interpretation: one line per message, starting with >>> when the application sent it, <<< when it received it, followed by the other party, the browser or the wallet. The org.pac4j.openid4vp logger tells what pac4j did with them: the transaction...