How to Issue and Verify PIDs with the EUDI Wallet
This comprehensive guide walks you through the complete process of issuing and verifying Person Identification Data (PID) credentials using the EU Digital Identity (EUDI) Reference Wallet with the walt.id Enterprise Stack.
Overview
The integration involves three main components:
- walt.id Enterprise Stack - Provides the issuer2 and verifier2 services
- EUDI Reference Wallet - The mobile wallet application for storing and presenting credentials
- Certificate Infrastructure - X.509 certificates for trust establishment
Prerequisites
- Docker and Docker Compose
- OpenSSL (for certificate generation)
- Android Studio (if building the EUDI wallet from source)
- An Android device or emulator with Android 8.0+
- ngrok or similar tunneling service (for local development)
Part 1: Setting Up Certificates
The EUDI wallet requires a proper X.509 certificate chain for trust verification. You'll need to generate certificates for both issuance and verification.
1.1 Generate Root Certificate
Create the root CA key and self-signed certificate:
# Generate root key
openssl ecparam -name prime256v1 -genkey -noout -out root-key.pem
# Generate self-signed root certificate (10 year validity)
openssl req -new -x509 -key root-key.pem -out root-cert.pem -days 3650 -subj "/CN=CustomRoot"
1.2 Generate Intermediate Certificate
Create the intermediate CA:
# Generate intermediate key
openssl ecparam -name prime256v1 -genkey -noout -out intermediate-key.pem
# Create CSR for intermediate CA
openssl req -new -key intermediate-key.pem -out intermediate.csr -subj "/CN=CustomIntermediate"
# Create extensions file for intermediate CA
echo "basicConstraints=critical,CA:TRUE" > intermediate.cnf
echo "keyUsage=critical,keyCertSign,cRLSign" >> intermediate.cnf
# Sign intermediate certificate with root
openssl x509 -req -in intermediate.csr -CA root-cert.pem -CAkey root-key.pem \
-CAcreateserial -out intermediate-cert.pem -days 365 -extfile intermediate.cnf
# Verify chain
openssl verify -CAfile root-cert.pem intermediate-cert.pem
1.3 Generate Issuer Leaf Certificate
Create the issuer's signing certificate:
# Generate issuer key
openssl ecparam -name prime256v1 -genkey -noout -out issuer-key.pem
# Create CSR
openssl req -new -key issuer-key.pem -out issuer.csr -subj "/CN=Issuer"
# Create extensions file
echo "basicConstraints=critical,CA:FALSE" > issuer-leaf.cnf
echo "keyUsage=critical,digitalSignature" >> issuer-leaf.cnf
# Sign issuer certificate
openssl x509 -req -in issuer.csr -CA intermediate-cert.pem -CAkey intermediate-key.pem \
-CAcreateserial -out issuer-cert.pem -days 365 -extfile issuer-leaf.cnf
1.4 Generate Verifier Leaf Certificate
Create the verifier's signing certificate:
# Generate verifier key
openssl ecparam -name prime256v1 -genkey -noout -out verifier-key.pem
# Create CSR
openssl req -new -key verifier-key.pem -out verifier.csr -subj "/CN=Verifier"
# Create extensions file
echo "basicConstraints=critical,CA:FALSE" > verifier-leaf.cnf
echo "keyUsage=critical,digitalSignature" >> verifier-leaf.cnf
echo "subjectAltName=DNS:example.com" >> verifier-leaf.cnf
# Sign verifier certificate
openssl x509 -req -in verifier.csr -CA intermediate-cert.pem -CAkey intermediate-key.pem \
-CAcreateserial -out verifier-cert.pem -days 30 -extfile verifier-leaf.cnf
# Convert to DER format (needed for x509_hash calculation)
openssl x509 -in verifier-cert.pem -outform DER -out verifier-cert.der
1.5 Convert Keys to JWK Format
For both the issuer and verifier configurations, you'll need the private key in JWK format. Use a PEM to JWK conversion tool:
# Using a conversion tool (example with node.js jose library or similar)
# The resulting JWK should look like:
{
"kty": "EC",
"crv": "P-256",
"x": "...",
"y": "...",
"d": "..."
}
You can then store these keys in the KMS service to reference later on, or use them inline in the configuration.
1.6 Calculate x509_hash for Verifier
The EUDI wallet uses the x509_hash client ID scheme. Calculate it as follows:
# Print SHA256 digest in Base64URL format
openssl dgst -sha256 -binary verifier-cert.der \
| openssl base64 -A \
| tr '+/' '-_' \
| tr -d '='
This will output something like: QZ_TUhNLzEVo7kFDWNG42hdOk0Z40DPXn57r6y9ku54
1.7 Extract x5c Chain for Configuration
Extract the certificate chain in base64 format for use in configurations:
# Extract base64 content from PEM files
sed -e '1{/^-----BEGIN /d;}' -e '${/^-----END /d;}' verifier-cert.pem | tr -d '
'
sed -e '1{/^-----BEGIN /d;}' -e '${/^-----END /d;}' intermediate-cert.pem | tr -d '
'
sed -e '1{/^-----BEGIN /d;}' -e '${/^-----END /d;}' root-cert.pem | tr -d '
'
2.3 Set Up ngrok Tunnel (for Local Development)
If running locally, set up an ngrok tunnel to make your issuer accessible to the mobile wallet:
ngrok http 3000 # Or whatever port the enterprise stack is running on
Add a host alias to the enterprise stack. See more here: Host Alias Service
Update your issuer's baseUrl to the ngrok URL (e.g., https://your-subdomain.ngrok-free.app).
Utilise the host for the url_host parameter when creating the verifier2 service or creating new verification sessions.
{
"url_config": {
"url_prefix": "https://your-custom-domain.com/v1/{target}/verifier2-service-api",
"url_host": "haip-vp://authorize"
},
}
Part 2: Setting Up the Issuer2 Instance
2.1 Configure Issuer Metadata
Create an issuer2 instance configuration with support for the EUDI PID format. Here's an example metadata configuration:
Please find more information regarding the issuer configuration here.
{
"traversable": true,
"_id": "waltid.tenant.issuer22",
"supportedCredentialTypes": {
"eu.europa.ec.eudi.pid.1": {
"format": "mso_mdoc",
"scope": "eu.europa.ec.eudi.pid.1",
"cryptographic_binding_methods_supported": ["jwk", "cose_key"],
"credential_signing_alg_values_supported": [-7, -9],
"proof_types_supported": {
"jwt": {
"proof_signing_alg_values_supported": ["ES256"]
}
},
"credential_metadata": {
"display": [
{
"name": "PID (MSO MDoc)",
"locale": "en"
}
],
"claims": [
{
"path": ["eu.europa.ec.eudi.pid.1", "family_name"],
"mandatory": true,
"display": [{ "name": "Family Name(s)", "locale": "en" }]
},
{
"path": ["eu.europa.ec.eudi.pid.1", "given_name"],
"mandatory": true,
"display": [{ "name": "Given Name(s)", "locale": "en" }]
},
{
"path": ["eu.europa.ec.eudi.pid.1", "birth_date"],
"mandatory": true,
"display": [{ "name": "Birth Date", "locale": "en" }]
},
{
"path": ["eu.europa.ec.eudi.pid.1", "nationality"],
"mandatory": true,
"display": [{ "name": "Nationality", "locale": "en" }]
},
{
"path": ["eu.europa.ec.eudi.pid.1", "expiry_date"],
"mandatory": true,
"display": [{ "name": "Expiry Date", "locale": "en" }]
},
{
"path": ["eu.europa.ec.eudi.pid.1", "issuing_authority"],
"mandatory": true,
"display": [{ "name": "Issuance Authority", "locale": "en" }]
},
{
"path": ["eu.europa.ec.eudi.pid.1", "issuing_country"],
"mandatory": true,
"display": [{ "name": "Issuing Country", "locale": "en" }]
}
]
},
"doctype": "eu.europa.ec.eudi.pid.1"
}
},
"tokenKeyId": "your-token-key-id",
"kms": "waltid.tenant.kms",
"baseUrl": "https://your-custom-domain.com",
"authProviderConfiguration": {
"name": "Keycloak",
"authorizeUrl": "https://your-keycloak-domain.com/realms/your-realm/protocol/openid-connect/auth",
"accessTokenUrl": "https://your-keycloak-domain.com/realms/your-realm/protocol/openid-connect/token",
"clientId": "issuer_api",
"clientSecret": "--",
"defaultScopes": [
"openid",
"profile"
]
},
}
2.2 Configure Issuance Defaults
For the wallet-initiated flow, configure default issuance settings:
{
// Follow the same configuration as above...
"traversable": true,
"_id": "waltid.tenant.issuer22.config1",
"supportedCredentialTypes": { ... },
"tokenKeyId": "your-token-key-id",
"kms": "waltid.tenant.kms",
"baseUrl": "https://your-custom-domain.com",
"authProviderConfiguration": { ... },
// With these additional fields:
"issuanceConfigurationDefaults": {
"eu.europa.ec.eudi.pid.1": {
"issuerKeyId": "your-issuer-key-id",
"x5Chain": [
"-----BEGIN CERTIFICATE-----
...intermediate cert...
-----END CERTIFICATE-----
"
],
"idTokenClaimsToCredentialDataJsonPathMappingConfig": {
"$.family_name": "$.['eu.europa.ec.eudi.pid.1'].family_name",
"$.given_name": "$.['eu.europa.ec.eudi.pid.1'].given_name"
},§
"mDocNameSpacesDataMappingConfig": {
"eu.europa.ec.eudi.pid.1": {
"entriesConfigMap": {
"birth_date": {
"type": "string",
"conversionType": "stringToFullDate"
},
"issue_date": {
"type": "string",
"conversionType": "stringToFullDate"
},
"expiry_date": {
"type": "string",
"conversionType": "stringToFullDate"
}
}
}
},
"staticCredentialData": {
"eu.europa.ec.eudi.pid.1": {
"family_name": "PLACEHOLDER",
"given_name": "PLACEHOLDER",
"nationality": "AT",
"birth_date": "1986-03-22",
"issue_date": "2024-01-01",
"expiry_date": "2029-01-01"
}
},
"authenticationMethod": "ID_TOKEN"
}
}
}
Part 3: Configuring the EUDI Wallet
Option A: Using Pre-built APKs
Coming Soon: Pre-built APKs with walt.id integration will be available for download. These APKs will come pre-configured with the walt.id trust certificates.
Option B: Building from Source
3.1 Clone the EUDI Wallet Repository
git clone https://github.com/eu-digital-identity-wallet/eudi-app-android-wallet-ui
cd eudi-app-android-wallet-ui
3.2 Add Root Certificate to Trust Store
Copy your root certificate to the resources folder:
cp root-cert.pem resources-logic/src/main/res/raw/waltid_root_cert.pem
3.3 Update WalletCoreConfigImpl.kt
Edit the file at core-logic/src/demo/java/eu/europa/ec/corelogic/config/WalletCoreConfigImpl.kt:
Add your certificate to the trust store configuration:
configureReaderTrustStore(
context,
R.raw.pidissuerca02_cz,
R.raw.pidissuerca02_ee,
R.raw.pidissuerca02_eu,
// ... other existing certs ...
R.raw.waltid_root_cert // Add this line
)
3.4 Configure the VCI Issuer URL (Optional)
This is only required if you want to use the wallet-initiated flow.
Update the vciConfig in the same file to include your issuer:
override val vciConfig: List<OpenId4VciManager.Config>
get() = listOf(
OpenId4VciManager.Config.Builder()
.withIssuerUrl(issuerUrl = "https://your-custom-domain.com/v1/tenant/issuer-service-api2/openid4vc/v1")
.withClientAuthenticationType(OpenId4VciManager.ClientAuthenticationType.AttestationBased)
.withAuthFlowRedirectionURI(BuildConfig.ISSUE_AUTHORIZATION_DEEPLINK)
.withParUsage(OpenId4VciManager.Config.ParUsage.IF_SUPPORTED)
.withDPoPUsage(OpenId4VciManager.Config.DPoPUsage.IfSupported())
.build(),
// ... other issuers ...
)
3.5 Build and Install
Open the project in Android Studio and build the demo flavor. Install the resulting APK on your device.
Part 4: Issuing PIDs
4.1 Issuer-Initiated Flow
This is the simpler flow where you generate a credential offer on the server side.
Step 1: Create an issuance session via the issuer2 API:
Please find more information regarding issuance requests here.
curl -X POST "https://your-custom-domain.com/v1/tenant/issuer-service-api2/issue" \
-H "Content-Type: application/json" \
-d '{
"credentialConfigurationId": "eu.europa.ec.eudi.pid.1",
"credentialData": {
"eu.europa.ec.eudi.pid.1": {
"family_name": "Doe",
"given_name": "John",
"birth_date": "1990-01-15",
"nationality": "DE",
"expiry_date": "2029-01-15",
"issuing_authority": "Test Authority",
"issuing_country": "DE"
}
}
}'
Step 2: The response contains an offerUrl. Display this as a QR code.
Step 3: Open the EUDI wallet and scan the QR code to receive the credential.
4.2 Wallet-Initiated Flow
In this flow, the user initiates issuance from within the wallet.
Step 1: Ensure your issuer is configured in the wallet's vciConfig (see Section 3.4).
Step 2: In the EUDI wallet app:
- Tap "Add Document"
- Select your issuer from the list
- Choose "PID (MSO MDoc)"
- Complete any required authentication (if configured with OAuth/OIDC)
- The credential will be issued and stored in the wallet
Part 5: Verifying PIDs
5.1 Configure the Verifier2 Instance
Create a verification session configuration:
Please find more information regarding the verifier configuration here. You can also find more information regarding the verification session creation here.
The example below is used to create a verification session which requests the birth date claim from the PID credential.
curl -X POST "https://your-custom-domain.com/v1/{TARGET}/verifier2-service-api/verification-session/create" \
-H "accept: */*" \
-H "Authorization: Bearer {yourToken}" \
-H "Content-Type: application/json" \
-d '{
"flow_type": "cross_device",
"url_config": {
"url_prefix": "https://your-custom-domain.com/v1/{TARGET}/verifier2-service-api",
"url_host": "haip-vp://authorize"
},
"core_flow": {
"dcql_query": {
"credentials": [
{
"id": "pid",
"format": "mso_mdoc",
"meta": {
"doctype_value": "eu.europa.ec.eudi.pid.1"
},
"claims": [
{
"path": ["eu.europa.ec.eudi.pid.1", "birth_date"],
"intent_to_retain": false
}
]
}
]
},
"signed_request": true,
"clientId": "x509_hash:YOUR_X509_HASH_HERE",
"key": {
"type": "jwk",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "YOUR_X_VALUE",
"y": "YOUR_Y_VALUE",
"d": "YOUR_D_VALUE"
}
},
"x5c": [
"BASE64_VERIFIER_CERT",
"BASE64_INTERMEDIATE_CERT",
"BASE64_ROOT_CERT"
]
},
"redirects": {
"success_redirect_uri": "https://example.com/verification-successful"
}
}
Replace the placeholder values:
YOUR_X509_HASH_HERE- The hash calculated in Section 1.6YOUR_X_VALUE,YOUR_Y_VALUE,YOUR_D_VALUE- Values from the verifier JWKBASE64_*_CERT- Certificate chain from Section 1.7{TARGET}- The target of the verifier2 service you are using.
It's important to note that in order to verify credentials from the EUDI wallet, you must use the HAIP profile, which is configured via the url_config.url_host parameter.
As part of HAIP, you must also use signed requests, which is configured via the signed_request parameter, alongside providing the necessary material through the x5c, clientId and key parameters.
5.2 Display the QR Code
The response includes a bootstrapAuthorizationRequestUrl. Generate a QR code from this URL:
{
"bootstrapAuthorizationRequestUrl": "haip-vp://authorize?client_id=x509_hash%3A...&request_uri=https%3A%2F%2F..."
}
You can use any QR code generator (e.g., goqr.me) to create the QR code.
5.3 Scan with EUDI Wallet
- Open the EUDI wallet
- Tap the scan button
- Scan the verification QR code
- Review the requested data
- Approve the presentation
5.4 Check Verification Results
Query the session status:
curl "https://your-custom-domain.com/v1/{TARGET}/verifier2-service-api/verification-session/{SESSION_ID}/info"
A successful verification returns:
{
"id": "session-id",
"status": "SUCCESSFUL",
"policyResults": {
"overallSuccess": true,
"vp_policies": {
"pid": {
"mso_mdoc/device-auth": { "success": true },
"mso_mdoc/device_key_auth": { "success": true },
"mso_mdoc/issuer_auth": { "success": true },
"mso_mdoc/issuer_signed_integrity": { "success": true },
"mso_mdoc/mso": { "success": true }
}
}
},
"presentedCredentials": {
"pid": [{
"credentialData": {
"docType": "eu.europa.ec.eudi.pid.1",
"eu.europa.ec.eudi.pid.1": {
"birth_date": "1990-01-15"
}
}
}]
}
}
Troubleshooting
Common Issues
- "Untrusted Issuer" Error
- Ensure the root certificate is properly added to the wallet's trust store
- Verify the certificate chain is complete and valid
- "Invalid Request" on Verification
- Check that the
x509_hashmatches the verifier certificate - Verify the certificate chain order in the
x5carray (leaf → intermediate → root)
- Check that the
- Wallet Can't Reach Issuer
- Ensure your ngrok tunnel is active (for local development)
- Check that the baseUrl in the issuer configuration matches the accessible URL
- Certificate Validation Failed
- Verify certificates haven't expired
- Check that the certificate chain is properly signed
Debug Resources
- Demo Video: EUDI Wallet Issuance Demo
- EUDI Wallet Repository: GitHub
Summary
This guide covered the complete flow for:
- Generating the required X.509 certificate chain
- Configuring the walt.id issuer2 instance for PID issuance
- Modifying the EUDI wallet to trust your certificates and issuer
- Issuing PIDs using both issuer-initiated and wallet-initiated flows
- Configuring the verifier2 instance
- Verifying PID credentials with proper policy checks
