Skip to content

EU Digital Identity Wallet (OpenID4VP)

Let people sign in by presenting the PID (Person Identification Data) credential from an EU Digital Identity Wallet. HelixIAM acts as the OpenID4VP verifier: it asks the wallet for the PID, verifies the presentation end to end, and signs the user in to the realm.

Status: Beta (core); Preview (Digital Credentials API, verify-only)

The core verifier — redirect same-device, QR cross-device, direct_post.jwt, and SD-JWT VC and mdoc presentations — is Beta as of 1.0.0-rc.11: implemented and tested, but external conformance is not yet certified and it needs your own relying-party agreement to exercise end to end. The Digital Credentials API and verify-only / step-up are newer, layered on top, and carry a Preview label instead. What has been tested so far:

  • The EU reference wallet. The EU Digital Identity Wallet reference app for Android signed in with SD-JWT VC and mdoc PIDs from the EU test issuer, same-device and cross-device: all four combinations passed. For cross-device, the QR content was handed to the wallet by an Android intent, not scanned with the camera, and x509_san_dns was not tested. See Interop: the EU reference wallet.
  • The OpenID Foundation conformance suite, run by the project itself. This is a self-run, not an OpenID Certification. See Conformance suite run.
  • Not yet tested: any national wallet, and the Digital Credentials API with a real wallet.

Added in 1.1.0-rc.1, all Preview and not yet tested with a real national wallet: credentials beyond the PID, transaction confirmation with verifier_info and multi-signed Digital Credentials API requests, DID issuers, verified data for applications, the verification API and credential issuance.

Bring your own registration

HelixIAM does not register you with a national wallet registrar, trust-list operator or scheme owner, and does not obtain an access or registration certificate for you. To accept real wallets you need your own relying-party registration, the X.509 access certificate and private key it gives you, and your own trust-list entry where one applies. Helix only uses what you already hold: you import the access certificate's private key and chain, and paste the certificate authorities that sign the PIDs you trust. Nothing in Helix is an eIDAS certification or a government approval.

What it is

A wallet connection is an identity provider like any OIDC or SAML broker, with protocol: "eudi-wallet" (openid4vp and wallet are accepted too). It is configured per realm.

When a user picks the wallet on the login page:

  1. Helix starts an OpenID4VP transaction and mints a signed, encrypted authorization request that asks for the PID.
  2. The wallet answers with a verifiable presentation, as an SD-JWT VC (dc+sd-jwt), an ISO mdoc (mso_mdoc), or both.
  3. Helix verifies it: the signature, the holder binding, the issuer's certificate chain against your trust anchors, the revocation status, and the requested claims.
  4. Helix resolves the presentation to a local account (subject strategy) and completes the sign-in through the realm's normal post-login flow (MFA, required actions).

The relying party receives an ordinary OIDC ID token. For a wallet login it carries acr: http://eidas.europa.eu/LoA/high, the level the wallet broker asserts once a presentation verifies; there is no separate substantial tier for wallet logins.

Capability Supported
Protocol OpenID4VP verifier, response mode direct_post.jwt (encrypted)
Credential formats SD-JWT VC (dc+sd-jwt), ISO mdoc (mso_mdoc)
Flows same-device (redirect to the wallet app), cross-device (QR code), and, opt-in per connection, the W3C Digital Credentials API (dc_api.jwt)
Revocation IETF Token Status List, fetched and verified by Helix
Trust PID issuer CA certificates you paste in (PEM), and/or signed EU trusted lists that Helix fetches and verifies; optionally DID issuers on an allow-list (Preview)
Linking administrative number, else an explicit account link
Step-up a verify-only connection confirms a signed-in user's own wallet and releases verified_claims
Other credentials a driving licence, diploma or qualification next to the PID, each with its own trust (Preview)
Data for applications per-application verified_claims in userinfo or tokens (Preview), or the verification API without a sign-in (Preview)
Transactions the user confirms a consent text or payment with the wallet (Preview)

Before you start

Set DB_ENCRYPTION first. The verifier access key and the per-realm wallet pepper are stored encrypted. Outside the dev profile, importing a verifier key, creating a wallet connection and starting a wallet transaction are refused unless DB_ENCRYPTION is set. Set it before you create the realm's first wallet connection: a pepper created without a key is not re-encrypted later, and wallet sign-ins for that realm fail once a key is set or changed.

Behind a proxy, set the proxy settings. The wallet endpoints are rate limited per client IP and use the server's host names. See Proxies, host names and rate limits.

In the console

  1. Open Federation & eIDs → Identity providers and click Add identity provider.
  2. Choose EUDI Wallet (OpenID4VP). The alias defaults to eudi-wallet.
  3. Access key. Import your verifier access key (private key and certificate chain) inline, or pick one you imported before.
  4. Credentials. Choose the credential formats, the PID claims to request, and paste the trust anchors or pick trusted list sources. The console previews each certificate and flags one that cannot be a trust anchor. Name the trusted issuers in the request sets HAIP trusted_authorities (Default / On / Off). Other credentials, Combinations and DID issuers add credentials beyond the PID and DID issuers (Preview).
  5. Policies. Set the account subject (or Verify only (step-up), see Verify-only), the revocation policy, the request and response settings, and the Digital Credentials API.
  6. Save. The connection is loaded at once and appears on the realm's login page.

The same form is the connection's detail page, with Access key, Credentials and Policies tabs, so you can come back to rotate the key, change trust anchors or adjust claims.

Advanced settings. The Policies tab ends with an Advanced block (collapsed while everything is at its default) for the expert keys: clockSkewSeconds (0–60, default 60), requestUriMethod (get / post), invocationScheme (default haip-vp; javascript, data, vbscript, file, blob and about are refused), pid.sdJwtVcts and pid.mdocDoctype (the accepted PID types), the parser limits limits.maxJwtBytes (64 KiB), limits.cborMaxBytes (256 KiB), limits.cborMaxDepth (16) and limits.cborMaxItems (10 000), and allowLegacyVcSdJwtTyp. A blank field keeps the server default, shown as the placeholder; values are validated as the server does. customCredentialQueries and accessCertificateKeyRef have no field on purpose: the validator refuses both.

Federation → Verified data lists every application's verified-claims policy and every verify-only connection's step-up settings, with remove and reset in the row menu.

Over the API

The examples assume you have set $HELIX_URL / $REALM and logged in — see Authenticating to the API. Every wallet endpoint below needs the manage-identity-providers admin permission, for reads and writes alike — except GET /trusted-lists (and its sub-paths), which needs only view-identity-providers as of 1.0.0-rc.11; writing a trusted list source still needs manage-identity-providers. Every write needs the CSRF header. See Admin roles (RBAC).

Step 1 — Import the verifier access key

This is the EC (P-256, P-384 or P-521) private key and certificate chain from your relying-party registration. Send the private key as PKCS#8 PEM and the chain leaf first. RSA keys, a self-signed leaf, a chain out of order and a name already used in the realm are refused.

# Refresh CSRF after any GET, then POST (see the auth guide for the helper)
curl -s -b cookies.txt -c cookies.txt "$HELIX_URL/admin/realms/$REALM/verifier-keys" >/dev/null
CSRF=$(awk '($6=="__Host-XSRF-TOKEN"||$6=="XSRF-TOKEN")&&$7~/^r1\./{t=$7} END{print t}' cookies.txt)

# Build the request from your PEM files (jq keeps the line breaks intact)
jq -n --rawfile key access.key.pem --rawfile chain access-chain.pem \
  '{name: "primary", privateKeyPem: $key, certificateChainPem: $chain}' > verifier-key.json

curl -s -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-XSRF-TOKEN: $CSRF" \
  -d @verifier-key.json \
  "$HELIX_URL/admin/realms/$REALM/verifier-keys"
{
  "keyRef": "software:master/primary",
  "name": "primary",
  "algorithm": "ES256",
  "createdAt": "2026-10-13T04:37:35.009617Z",
  "chainLength": 1,
  "certificate": {
    "subject": "C=NL,O=Example,CN=auth.example.com",
    "issuer": "C=NL,O=Example,CN=Example RP Access CA",
    "serialNumber": "175935999ced01eef1475f64a8813d653d95e150",
    "notBefore": "2026-10-13T04:37:17Z",
    "notAfter": "2027-04-11T04:37:17Z",
    "ca": false,
    "selfSigned": false,
    "dnsNames": ["auth.example.com"],
    "keyAlgorithm": "EC P-256",
    "trustAnchorProblem": "A trust anchor is not a CA certificate"
  }
}

The response is 201 with the key's reference (keyRef), which the connection uses, and the leaf certificate's public facts. The private key is write-only: it is never returned, logged or audited. (trustAnchorProblem describes the certificate as a trust anchor and is expected for an access-certificate leaf.) A refusal is a 400 with the usual shape:

{
  "message": "A signing key named 'primary' already exists in this realm",
  "fieldErrors": { "name": "A signing key named 'primary' already exists in this realm" }
}

List the realm's keys (public facts only) with GET:

curl -s -b cookies.txt "$HELIX_URL/admin/realms/$REALM/verifier-keys"

Key storage

The access key is stored in the database, encrypted with DB_ENCRYPTION. There is no HSM or KMS key provider for it today.

Step 2 — Check your trust anchors

Trust anchors are the CA certificates that sign the PIDs you accept, pasted as PEM. Before you save, you can preview a bundle; nothing is stored:

curl -s -b cookies.txt -c cookies.txt "$HELIX_URL/admin/realms/$REALM/verifier-keys" >/dev/null
CSRF=$(awk '($6=="__Host-XSRF-TOKEN"||$6=="XSRF-TOKEN")&&$7~/^r1\./{t=$7} END{print t}' cookies.txt)

jq -n --rawfile pem pid-issuer-ca.pem '{pem: $pem}' > preview.json

curl -s -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-XSRF-TOKEN: $CSRF" \
  -d @preview.json \
  "$HELIX_URL/admin/realms/$REALM/wallet/certificates/preview"
{
  "certificates": [
    {
      "subject": "C=DE,O=Example,CN=Example PID Issuer CA",
      "issuer": "C=DE,O=Example,CN=Example PID Issuer CA",
      "serialNumber": "30791c31a43e1786457a8529f8d9b14e49f28787",
      "notBefore": "2026-10-13T04:37:17Z",
      "notAfter": "2027-10-13T04:37:17Z",
      "ca": true,
      "selfSigned": true,
      "dnsNames": [],
      "keyAlgorithm": "EC P-256",
      "trustAnchorProblem": null
    }
  ]
}

trustAnchorProblem: null means the certificate can serve as a trust anchor. A bundle holds at most 50 certificates.

Step 3 — Create the wallet connection

curl -s -b cookies.txt -c cookies.txt "$HELIX_URL/admin/realms/$REALM/identity-providers" >/dev/null
CSRF=$(awk '($6=="__Host-XSRF-TOKEN"||$6=="XSRF-TOKEN")&&$7~/^r1\./{t=$7} END{print t}' cookies.txt)

jq -n --rawfile anchors pid-issuer-ca.pem --arg keyRef "software:$REALM/primary" '{
  alias: "eudi-wallet",
  protocol: "eudi-wallet",
  displayName: "EU Digital Identity Wallet",
  enabled: true,
  config: {
    accessKeyRef: $keyRef,
    trustAnchors: $anchors,
    credentialFormats: "[\"dc+sd-jwt\",\"mso_mdoc\"]"
  }
}' > wallet-idp.json

curl -s -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-XSRF-TOKEN: $CSRF" \
  -d @wallet-idp.json \
  "$HELIX_URL/admin/realms/$REALM/identity-providers"
{
  "realmId": "master",
  "alias": "eudi-wallet",
  "protocol": "eudi-wallet",
  "displayName": "EU Digital Identity Wallet",
  "enabled": true,
  "config": {
    "accessKeyRef": "software:master/primary",
    "trustAnchors": "-----BEGIN CERTIFICATE-----\n…\n-----END CERTIFICATE-----\n",
    "credentialFormats": "[\"dc+sd-jwt\",\"mso_mdoc\"]",
    "accessCertificatePrivateKeySet": false,
    "subjectPepperSet": false
  }
}

Use the keyRef from step 1 as accessKeyRef. The response is 201. Secret keys are never returned: they come back as …Set flags (see secrets are write-only). Update and delete work like any provider: PUT / DELETE /admin/realms/{realm}/identity-providers/{alias}.

Configuration keys

config is a flat map of strings. Nested settings are dotted, lists are JSON arrays of strings, enums are case-insensitive. Everything you leave out takes the default below.

Key Default Meaning
accessKeyRef — the verifier key reference from step 1
trustAnchors — PEM CA certificates that sign the PIDs you accept; optional when trustedLists.sources is set (both may be set, and are combined)
trustedLists.sources — JSON array of the realm's trusted list source names whose verified PID issuer anchors are trusted; an unknown name is a 400
trustedLists.serviceTypes every type the sources load JSON array of service type URIs that narrows trustedLists.sources
credentialFormats ["dc+sd-jwt","mso_mdoc"] which formats to ask for
clientIdScheme x509_hash how the verifier identifies itself to the wallet
invocationScheme haip-vp the URL scheme of the same-device "Open wallet" link
responseMode direct_post.jwt encrypted response; plain direct_post is allowed only in the dev profile
requestUriMethod get how the wallet fetches the request object: get or post
dcql.trustedAuthorities (unset) send HAIP's trusted_authorities in the DCQL query; unset means on for x509_hash with direct_post.jwt, off otherwise — see HAIP trusted_authorities
dcApi.enabled false also offer the W3C Digital Credentials API on the wallet page
dcApi.request signed signed (openid4vp-v1-signed), unsigned (openid4vp-v1-unsigned) or multisigned (openid4vp-v1-multisigned, Preview)
dcApi.expectedOrigins (empty: the origin of IDP_BASE_URL) JSON array of the web origins a DC API response may be bound to
dcApi.additionalKeyRef — the second realm key of a multi-signed request
verifierInfo.source / verifierInfo.format / verifierInfo.json none OpenID4VP verifier_info (Preview)
credentials / credentialSets — credentials beyond the PID and how they combine (Preview)
holderBinding.sameKey false refuse presentations bound to different holder keys
didIssuers — PID issuers identified by a DID (Preview)
transactionData.types — verify-only: the transaction types the connection may confirm (Preview)
transactionTtlSeconds 300 how long a sign-in may take, 60–900
pid.requestedClaims ["given_name","family_name","birthdate"] the PID claims to request
pid.sdJwtVcts ["urn:eudi:pid:1","urn:eudi:pid:de:1"] accepted SD-JWT VC types
statusList.required false refuse a PID that carries no status claim
statusList.onUnavailable FAIL_CLOSED or FAIL_OPEN_WITH_AUDIT; see Revocation
statusList.maxCacheSeconds 900 how long a fetched status list is cached, 0–900
statusList.allowedHosts [] (any https host) status-list host names Helix may fetch from
revocationChecking HARD_FAIL issuer-certificate revocation (CRL / OCSP): HARD_FAIL, SOFT_FAIL or OFF — see Issuer certificate revocation
subject.strategy ADMINISTRATIVE_NUMBER or ATTRIBUTE_TUPLE; see the subject. NONE makes the connection verify-only
subject.acknowledgeWeakSubject false opt in to the weak attribute-tuple link (refused with NONE)
verify.linkedProvider — with NONE only, and then required: the alias of the realm's wallet sign-in connection whose links prove whose wallet it is
verify.releasedClaims nothing with NONE only: JSON array of the claims released as verified_claims
minimumLoa http://eidas.europa.eu/LoA/high minimum level of assurance
clockSkewSeconds 60 allowed clock skew
limits.maxJwtBytes 65536 size cap for a presentation and each JWT in it

An invalid combination is refused with 400 {message, fieldErrors} naming the field. For example, ATTRIBUTE_TUPLE without the acknowledgement:

curl -s -b cookies.txt -c cookies.txt "$HELIX_URL/admin/realms/$REALM/identity-providers" >/dev/null
CSRF=$(awk '($6=="__Host-XSRF-TOKEN"||$6=="XSRF-TOKEN")&&$7~/^r1\./{t=$7} END{print t}' cookies.txt)

jq '.config["subject.strategy"] = "ATTRIBUTE_TUPLE"' wallet-idp.json > wallet-idp-tuple.json

curl -s -b cookies.txt -X PUT \
  -H "Content-Type: application/json" -H "X-XSRF-TOKEN: $CSRF" \
  -d @wallet-idp-tuple.json \
  "$HELIX_URL/admin/realms/$REALM/identity-providers/eudi-wallet"
{
  "message": "subject.strategy ATTRIBUTE_TUPLE requires subject.acknowledgeWeakSubject=true",
  "fieldErrors": {
    "config.subject.strategy": "subject.strategy ATTRIBUTE_TUPLE requires subject.acknowledgeWeakSubject=true"
  }
}

Full endpoint set

Method & path (under /admin/realms/{realm}) Purpose
GET /verifier-keys · POST /verifier-keys List / import verifier access keys
POST /verifier-keys/generate Generate a verifier key in an HSM / KMS backend (see Key custody)
POST /verifier-keys/{name}/csr · PUT /verifier-keys/{name}/certificate CSR for a generated verifier key / attach its certificate chain
GET /trusted-lists · POST /trusted-lists List / add trusted list sources
GET · PUT · DELETE /trusted-lists/{id} Read / update (needs configVersion) / delete a source
GET /trusted-lists/{id}/anchors The verified services of the source's last good list
POST /trusted-lists/{id}/refresh Refresh a source now
POST /trusted-lists/{id}/reset Reset and reload a source
POST /wallet/did/resolve Resolve a DID and show its keys (nothing stored)
GET /flow-engine Whether the flow engine is on ({"enabled": …}), needed by verify-only after a password
GET /wallet/metadata The PID claims, default claims and formats this server supports
POST /wallet/certificates/preview Parse a PEM bundle and show each certificate (nothing stored)
GET /identity-providers · POST /identity-providers List / create providers, including wallet connections
GET · PUT · DELETE /identity-providers/{alias} Read / update / delete a provider

PID claims

Ask only for what you need. The default is given_name, family_name and birthdate. The server lists every claim it can request, and whether an mdoc PID carries it:

curl -s -b cookies.txt "$HELIX_URL/admin/realms/$REALM/wallet/metadata"
{
  "pidClaims": [
    { "name": "family_name", "mdoc": true },
    { "name": "given_name", "mdoc": true },
    { "name": "birthdate", "mdoc": true },
    { "name": "place_of_birth", "mdoc": true },
    { "name": "nationalities", "mdoc": true },
    …
    { "name": "address.house_number", "mdoc": false },
    { "name": "personal_administrative_number", "mdoc": true },
    …
  ],
  "defaultRequestedClaims": ["given_name", "family_name", "birthdate"],
  "credentialFormats": ["dc+sd-jwt", "mso_mdoc"],
  "devProfile": true
}

devProfile is true only on a server running the dev profile (this capture), false elsewhere.

Claim names are the SD-JWT VC names, with nested members dotted (address.country); an mdoc presentation is mapped to the same names. A claim marked "mdoc": false is requested from SD-JWT VC only. personal_administrative_number and issuing_country are requested optionally for the subject.

What lands on the Helix user: given_name, family_name, birthdate (as YYYY-MM-DD), nationalities, place_of_birth.*, address.*, issuing_country and issuing_authority, plus helix_wallet_format (dc+sd-jwt, mso_mdoc or both) and helix_wallet_issuer (the issuer DN). The administrative number feeds the subject only and is never a user attribute; a PID carries no email. Your per-provider attribute mappers apply on top, as for every broker.

When a wallet answers in both formats, given_name, family_name and birthdate must agree or the login is refused.

Trust anchors

trustAnchors holds the PEM CA certificates of the PID issuers you accept. A presentation whose issuer chain does not validate to one of them is refused. Pasted anchors do not update themselves; to have Helix follow a published list instead (or as well), use trusted lists.

For mdoc credentials, the signer of a status list must chain to the same issuing CA as the credential's signer. Helix checks this on the validated chain, never on the certificates the wallet presents.

HAIP trusted_authorities in the request

HAIP requires verifiers to support DCQL's trusted_authorities. Each PID Credential Query can carry {"type": "aki", "values": [...]}: the base64url key identifiers of the connection's trust anchors and pinned intermediates. With trusted lists, the values follow the lists' current anchors at each request.

  • Default. dcql.trustedAuthorities (console: Credentials → Name the trusted issuers in the request, Default / On / Off). Unset means on for the HAIP profile (x509_hash with direct_post.jwt, both defaults) and off otherwise, so existing HAIP-profile connections send it without a configuration change.
  • It only tells the wallet which issuers to offer. Validating the chain to your anchors still decides what is accepted.
  • Key identifiers are never guessed. If any anchor or pinned intermediate has no SubjectKeyIdentifier extension, the query leaves trusted_authorities out and the server logs one WARN when it loads the connection. Saving the setting explicitly On with such an anchor is a 400 on dcql.trustedAuthorities.
  • Escape hatch. If a wallet answers with no credential (it filtered on trusted_authorities and found no match), set the connection to Off.

Rolling upgrades with wallet connections

A node of a release before trusted_authorities refuses a DCQL query that carries it. A wallet login started on an upgraded node and answered on an old node that shares the Redis or PostgreSQL transaction store therefore fails. Either upgrade every replica in one step; or drain wallet logins first (no new wallet logins on old nodes, then wait out transactionTtlSeconds, 300 s by default); or set HAIP-profile connections to Off (dcql.trustedAuthorities=false) for the duration of the roll and back to Default afterwards. The new release accepts queries with or without it.

Trust anchors from EU trusted lists

Instead of (or as well as) pasting PEM anchors, a wallet connection can take its PID issuer anchors from published, signed trusted lists (ETSI TS 119 612 XML lists and lists of trusted lists, ETSI TS 119 602 LoTE JSON) that Helix fetches, verifies and refreshes itself. You add a source once per realm (Federation & eIDs → Trusted lists, /admin/realms/{realm}/trusted-lists) with its URL and pinned signer certificates, then name it in trustedLists.sources:

"config": {
  "accessKeyRef": "software:master/primary",
  "trustedLists.sources": "[\"eudi-pid-providers\"]"
}

A list that cannot be fetched or verified keeps its last good anchors until its NextUpdate plus a grace period, then fails closed (TRUST_LIST_UNAVAILABLE). Signer rotation (the LOTL pivot), LoTE pointers, retry backoff, the integrity checks and Reset and reload are described on their own page: Trusted lists.

Helix resolves a wallet sign-in to a local account in this order:

  1. The PID carries personal_administrative_number and that number is linked to a user of the realm → sign in as that user.
  2. The number is not linked, but the same holder's attribute tuple (below) is linked → sign in as that user and, only after the whole login has succeeded, also link the number to that user, so later sign-ins use the stronger number.
  3. The number is unlinked and has no linked tuple → provision a new user just in time and link the number, when the account-linking policy allows just-in-time provisioning (it does by default).
  4. The PID carries no administrative number → the wallet cannot create an account. The user must sign in another way first and link the wallet from the account console.

Account linking

Users link a wallet from the account console (/realms/{realm}/account): the EU Digital Identity Wallet section has a Link wallet button. Linking needs a recent sign-in and runs the same wallet page (QR or same-device). Unlinking asks for a step-up and a confirmation. Both are audited (ACCOUNT_WALLET_LINK, ACCOUNT_WALLET_UNLINK).

Whenever a wallet is linked to an account — from the account console, or by the number upgrade in step 2 — the account's email address is told, and asked to unlink it and change the password if it was not them. The message uses the wallet-linked-notice template (English and Dutch defaults, editable under Messaging → Templates) and carries the realm name only, never PID data. No email address or no email provider means no notice.

The weak attribute-tuple fallback

With subject.acknowledgeWeakSubject=true, a PID without an administrative number can still be linked (never auto-created) by an HMAC of issuing_country, family_name, given_name and birthdate, plus place_of_birth.country/region/locality exactly when pid.requestedClaims includes place_of_birth. The HMAC key is a per-realm pepper, stored encrypted.

Residual risk and stability

Two people who share the same tuple (name, birthdate, issuing country, and the configured place-of-birth parts) are indistinguishable: if one has linked a wallet, the other's wallet resolves to the same account. That is why the fallback is opt-in.

Adding or removing place_of_birth in pid.requestedClaims changes every tuple this provider computes and orphans every existing tuple link. Those users keep their account but must link their wallet again. Administrative-number links are not affected.

For step-up without sign-in, see Verify-only (step-up) (subject.strategy=NONE).

Revocation and status lists

Helix checks credential status with the IETF Token Status List, fetched and verified by Helix itself. The policy is asymmetric on purpose:

  • Always fails closed when the status list cannot be trusted: a bad signature, an untrusted or expired chain, a wrong typ or alg, a sub that does not match the fetched URI, or a URI refused by policy (not https, blocked by the egress guard, or not in statusList.allowedHosts). A refused URI is never fetched.
  • May fail open only with statusList.onUnavailable=FAIL_OPEN_WITH_AUDIT, and only for transient problems (a timeout, a 5xx, a DNS failure). The login then proceeds and an audit event WALLET_STATUS_CHECK_FAILED naming the wallet transactionId records that it did so without a revocation check.

The defaults are onUnavailable=FAIL_CLOSED (an unreachable list blocks sign-in) and statusList.required=false (a PID with no status claim is accepted).

Every status-list fetch goes through the same outbound URL guard as every other admin-configured outbound call: https only, no loopback, private, link-local or cloud-metadata destination. The connection goes to the address the guard checked (no DNS rebinding), TLS is still verified against the host name; connect 3 s, 5 s in total including DNS, body at most 1 MB. A TLS failure or an answer that is not valid HTTP fails closed even under FAIL_OPEN_WITH_AUDIT. Set statusList.allowedHosts to narrow it to your issuers' status hosts.

Verified status lists are cached in one cache shared by every wallet connection: helix.wallet.status-list.cache-max-entries (default 64) and helix.wallet.status-list.cache-max-bytes (default 32 MB of decoded list bytes).

Issuer certificate revocation (CRL / OCSP)

Separately from the credential's status list, the issuer's own signing certificate (and any intermediate below your trust anchor) is checked for revocation, per connection (revocationChecking; console: Credentials → Issuer certificate revocation):

Mode Status cannot be determined Revoked
HARD_FAIL (default) refused (ISSUER_UNTRUSTED) refused
SOFT_FAIL accepted, with a warning naming the certificate refused
OFF not checked, nothing fetched not checked

Under HARD_FAIL a certificate that names no CRL or OCSP endpoint is refused: choose SOFT_FAIL for an issuer that publishes neither, or whose endpoints you cannot reach; OFF only for testing. Connections stored before 1.0.0-rc.9 without a value now run HARD_FAIL.

As of 1.0.0-rc.11, setting revocationChecking to OFF in the console requires an explicit confirmation: a checkbox ("I understand a credential signed with a revoked issuer key will be accepted") must be ticked before the form can be saved with OFF, repeated on the connection wizard's Review step. This closes an internal-pentest finding that the console warned about OFF but did not require any acknowledgement before saving it — see Internal penetration test (2026-10-15).

Nothing is fetched until the chain validates to one of your trust anchors. Each certificate is checked on its own (OCSP first, then CRL distribution points, at most 2 URLs of each per certificate), over http or https through the outbound guard, without redirects: connect 3 s, 5 s per fetch, 10 s per chain. The JDK itself never fetches. Answers are cached until nextUpdate (at most 24 h), shared by every connection:

Setting Default Meaning
helix.wallet.revocation.max-crl-bytes 10485760 (10 MB) largest CRL downloaded (at most 100 MB)
helix.wallet.revocation.cache-max-entries 256 most cached OCSP responses and CRLs
helix.wallet.revocation.cache-max-bytes 33554432 (32 MB) most cached bytes
helix.outbound.fetch.wallet.max-concurrent 16 most issuer-revocation and status-list fetches at once (old name helix.wallet.fetch.max-concurrent still read)

With every endpoint slow or unreachable, one credential can take about 25 s to verify; cached answers make the usual case fast.

Same-device and cross-device flows

Both start from the realm's login page and land on the same wallet page:

  • Cross-device (QR). The page shows a QR code. The user scans it with the wallet on their phone; the browser polls the transaction's status and signs in when the wallet has answered.
  • Same-device. The Open wallet link (a haip-vp:// URL by default) hands the request to the wallet app on the same device, which returns the browser to Helix to finish.

Either flow accepts SD-JWT VC or mdoc, as configured. The wallet fetches the request from, and posts its response to, URLs Helix builds from IDP_BASE_URL, never from the incoming request:

Path Called by
GET /realms/{realm}/broker/{alias} the browser — starts a wallet transaction
GET /realms/{realm}/broker/{alias}/wallet the browser — the wallet page (QR and Open-wallet link)
POST /realms/{realm}/broker/{alias}/wallet/status the browser — polls the transaction (CSRF-protected)
POST /realms/{realm}/broker/{alias}/wallet/dc-api/request · …/dc-api/response the browser — the Digital Credentials API, when the connection turns it on
GET/POST /realms/{realm}/oid4vp/{alias}/request/{requestId} the wallet — fetches the signed request
POST /realms/{realm}/oid4vp/{alias}/response/{responseId} the wallet — posts the encrypted response

Request objects can be fetched again until the response arrives. The first fetch of a request_uri binds the login to that flow (same-device or cross-device); the other invocation is 404 from then on. The same request_uri returns the same request again until the wallet has posted its response; after that, or once the login ended or expired, it is 404. The response itself is single use. A request_uri_method=post re-fetch echoes the wallet_nonce of that POST, so it is not byte-identical to the first fetch. (Before this change each request_uri was served once; the OpenID Foundation suite's request-uri-fetched-twice test failed on that.)

A QR code is valid for the transaction's lifetime (transactionTtlSeconds, 300 s by default); after that it is an unknown transaction. If a login fails, the user is told one of four things only: declined, not_accepted, expired or technical. The specific check is in the audit log (WALLET_LOGIN_SUCCESS / WALLET_LOGIN_FAILURE) and the server log, never in the browser.

Cross-device QR and the ARF

Separate EUDI ARF guidance recommends that conformant EU wallets do not support redirect-based cross-device flows, and points to the W3C Digital Credentials API instead. The EU reference wallet (2026.10.43-Demo) does accept Helix's cross-device request; whether national wallets will is still open. Helix offers the Digital Credentials API as an opt-in per connection, with the QR code and the wallet link kept as the fallback.

Proxies, host names and rate limits

The wallet endpoints are rate limited per client IP, in memory on each replica:

Endpoints Burst Refill Properties
/oid4vp/** (any method) 120 120 / minute helix.ratelimit.wallet.burst, helix.ratelimit.wallet.refill-per-minute
starting a wallet login (GET /broker/<wallet alias>) 30 30 / minute helix.ratelimit.wallet-start.burst, helix.ratelimit.wallet-start.refill-per-minute

Behind an ingress or load balancer, list it in HELIX_TRUSTED_PROXIES, or every phone and browser shares the proxy's one budget. Phones reach the host of IDP_BASE_URL, which is always allowed; a wallet demo on another host name needs that host in HELIX_ALLOWED_HOSTS. See Security hardening.

Transaction store and multiple replicas

Each wallet login's in-flight state (nonce, decryption key, browser binding, request, lifetime) lives in a transaction store shared by every replica, so a cross-device login whose wallet requests land on another pod still works. No sticky sessions are needed.

Store Used when
PostgreSQL (table wallet_transaction) the session store is queue, or neither the session nor the token store is Redis
Redis the session store is redis, or the token store is redis and the session store is not queue
In memory only when you set HELIX_WALLET_TRANSACTION_STORE=memory — one replica, development and tests

helix.wallet.transaction-store (env HELIX_WALLET_TRANSACTION_STORE: jdbc, redis or memory) overrides the choice. A server with neither Redis nor a database that was not asked for memory refuses to start. Both shared stores make every transition atomic and single-use and keep the request ids, response id, decryption key and verified identity encrypted with DB_ENCRYPTION. PostgreSQL deletes expired rows every helix.wallet.transaction-sweep-seconds (default 60). Expiry uses the application clock, so keep the replicas' clocks in sync (NTP).

Configuration changes reach every replica without a restart. Every identity-provider save or delete (admin API, console, realm import) bumps the realm's row in idp_config_version. Each replica polls it every helix.federation.store.poll-interval-seconds (env HELIX_FEDERATION_STORE_POLL_INTERVAL_SECONDS, default 10, 0 turns polling off) and reloads the realms that changed. This needs HELIX_FEDERATION_STORE_ENABLED (default true). After restoring the database from a backup, restart every replica: a replica only reloads a realm whose version went up.

The W3C Digital Credentials API

OpenID4VP 1.0 Appendix A defines how a verifier's web page asks for a credential through the browser itself (navigator.credentials.get with a digital request). The browser and the operating system show the user which wallets can answer and who is asking, and hand the answer back to the same page; cross-device works through the platform (a phone nearby). HAIP 1.0 §5.2 makes it the HAIP way to present over the web.

Status: Preview — not yet tested with a real wallet

The Digital Credentials API path has not been run against a real wallet. It is covered by unit tests against the spec's example vector and by an end-to-end test in which the project's own test wallet plays both the browser and the wallet. An attempt to repeat the reference-wallet emulator run over the DC API stopped before it started: the emulator image used ships a Chrome version that predates the API.

Turning it on. Per connection, off by default (console: Policies → Digital Credentials API):

Key Default Meaning
dcApi.enabled false offer Continue with your wallet on the wallet page, in browsers that support the API
dcApi.request signed signed (openid4vp-v1-signed: a JWS by the access certificate, with expected_origins) or unsigned (openid4vp-v1-unsigned); prefer signed
dcApi.expectedOrigins empty JSON array of the web origins the page is served on, written as a browser reports them (https://login.example.eu, no path, no trailing slash); at most 10; empty means the origin of IDP_BASE_URL

The response is dc_api.jwt (encrypted to the transaction's own key) whenever responseMode is direct_post.jwt, and plain dc_api only with the dev-only direct_post. The QR code and the wallet link stay on the page as the fallback.

Origins come from configuration only, never from the request. Each expected origin must be on a host the server answers for: the host of IDP_BASE_URL or one listed in HELIX_ALLOWED_HOSTS. An SD-JWT VC presentation must carry aud = origin:<expected origin>; an mdoc DeviceAuth must verify over the OpenID4VPDCAPIHandover SessionTranscript of an expected origin. A presentation made for the redirect flows is refused over the DC API, and the other way round.

Protections. The two page endpoints (POST /realms/{realm}/broker/{alias}/wallet/dc-api/request and …/dc-api/response) need the browser session that started the login and the CSRF header, take strict JSON of at most 256 KiB (helix.wallet.max-request-bytes), and claim the transaction atomically: a second post, or a post after a wallet already fetched the QR or same-device request, is refused. Refusals are audited as WALLET_LOGIN_FAILURE with invocation=dc_api.

Not supported: the ISO org-iso-mdoc protocol (ISO 18013-7 Annex C; deferred, see Transaction confirmation) and native apps as callers. Multi-signed requests, transaction_data and verifier_info are supported as Preview: see Transaction confirmation.

Verify-only (step-up)

Some applications do not need the wallet to sign the user in; they need proof, for a user who is already signed in, that their own wallet confirms it is them (a step-up to eIDAS high), or a verified detail such as "18 or over". A wallet connection with subject.strategy=NONE does exactly that.

Status: Preview — new in 1.0.0-rc.11 (see the limitations below for what is not in this release yet).

What it never does. It never signs anyone in (it is not on the login page, and /realms/{realm}/broker/{alias} refuses to start it), never creates an account, and by default never links a wallet (the account console neither lists nor links it) and stores nothing on the account. Two opt-in settings change the last two: binding an unlinked wallet by matching profile attributes, and keeping verified attributes on the profile. See Wallet step-up and profile (Preview).

Set it up

  1. Keep (or create) the realm's normal wallet sign-in connection. Users link their wallet with it, as above.
  2. Create a second wallet connection with subject.strategy=NONE (console: Policies → Verify only (step-up)):

    Key Meaning
    verify.linkedProvider Required. The alias of the sign-in connection of step 1, in the same realm. A presented wallet must be linked to the signed-in user there. Its minimumLoa may not be lower than the linked connection's (400)
    pid.requestedClaims What the wallet is asked for. Keep it small. The administrative number and issuing country are always asked for optionally, to bind the wallet; they are never released
    verify.releasedClaims JSON array of what goes to the application: requested claims this verifier maps (given_name, family_name, birthdate, nationalities, place_of_birth, address.*, issuing_country, issuing_authority) or age_over_NN (1–150, worked out from the verified birth date, which must be requested). Default: nothing: the step then only proves the wallet and raises the level of assurance
  3. Add the step EU Digital Identity Wallet verification (eudi-wallet-verify, setting providerAlias = the verify-only connection) to a sign-in flow after the first factor, and bind that flow to the application. A flow save without providerAlias is refused (400).

Password logins need the flow engine

A login that starts with a password (or a magic link) runs flow steps only with helix.flow-engine.enabled=true (HELIX_FLOW_ENGINE_ENABLED, default false); a federated first factor always runs them. With the engine off, nothing is skipped silently: such a login whose flow has the wallet step is refused (/login?error=flow), saving or binding such a flow is a 400, and the console shows a banner. GET /admin/realms/{realm}/flow-engine tells you which applies.

Validation (400 {message, fieldErrors}): NONE needs verify.linkedProvider (another alias), refuses subject.acknowledgeWeakSubject=true, and accepts only the release entries above; verify.* without NONE is refused. An application's single sign-on setting cannot name a verify-only connection.

What the application gets

After the first factor, the step sends the browser to the usual wallet page (the copy says "Confirm", not "Sign in"). The presentation is verified exactly like a login. Then the wallet must be bound to this user through the linked connection's links: the administrative number when linked (a number linked to anyone else is refused), otherwise the attribute tuple when the linked connection accepts weak subjects. A wallet that is not linked at all, or is another person's, is refused, with Try again.

On success the login's acr becomes http://eidas.europa.eu/LoA/high. The released claims go into the ID token of the authorization request the step started for, as an OIDC Identity Assurance-style verified_claims object, only if that request asked for the verified_claims scope (a standard client scope, seeded in every realm; register it on the client). Without the scope the step still raises acr, and nothing is released:

"acr": "http://eidas.europa.eu/LoA/high",
"verified_claims": {
  "verification": {"trust_framework": "eidas", "assurance_level": "high",
                   "time": "2026-10-15T10:00:00Z"},
  "claims": {"age_over_18": true, "nationalities": "DE,NL"}
}
  • assurance_level is the OpenID Connect for Identity Assurance value high (before 1.1.0-rc.1 it was the LoA URI). An application with a verified-claims policy gets its policy's release instead, from this step's verification, in userinfo by default.
  • verified_claims is pinned to that one authorization request for 5 minutes. It is in the ID token of the code exchange only, never in a refreshed ID token, userinfo or an access token, and nothing is persisted on the account. Protocol mappers cannot set it.
  • Pushed authorization requests (PAR): a PAR client sends only client_id and request_uri on the front channel, so no release is pinned: a PAR client gets the step-up acr but no verified_claims.
  • Stepping up an existing session: an application sends acr_values=http://eidas.europa.eu/LoA/high. When the session's acr does not satisfy it, the user signs in again once for that request and the application's flow (with the wallet step) runs. acr_values stays voluntary: if it is still not met (and always with prompt=none), the code carries the lower, actual acr. Relying parties must check acr in the ID token. Once raised, acr stays for the SSO session until the next login, unless the connection has a step-up lifetime. When the client's flow has a top-level REQUIRED wallet step, a signed-in user steps up without the first factor (Preview).
  • Audit: WALLET_VERIFY_SUCCESS (released claim names only) and WALLET_VERIFY_FAILURE (with a reason such as BINDING_MISMATCH or SUBJECT_UNAVAILABLE). No PID value is audited or logged.

Not in this release: a realm-wide default step-up lifetime; keeping attributes from a wallet sign-in connection; step-up without the first factor for the essential-claims step-up and for PAR requests. The step-up lifetime, step-up without the first factor, binding by matching attributes and keeping verified attributes are on Wallet step-up and profile. verified_claims in userinfo and access tokens, the OIDC claims parameter, discovery metadata and the claims of other credentials are released per application: see Verified data for applications. Verifying attributes without a sign-in is the verification API. Having the user confirm a transaction is transaction confirmation.

Interop: the EU reference wallet

On 2026-10-15 the project tested the verifier against the EU Digital Identity Wallet reference app for Android (eudi-app-android-wallet-ui 2026.10.43-Demo, the released APK, demo flavour) on an Android 15 emulator, with PIDs from the EU reference test issuer (issuer.eudiw.dev):

Flow SD-JWT VC PID mdoc PID
Same-device Pass Pass
Cross-device (QR) Pass (QR content handed to the wallet by an Android intent, not the camera) Pass (same)

Every run used the defaults (x509_hash, haip-vp://, direct_post.jwt, issuer revocation HARD_FAIL, status list FAIL_CLOSED); no Helix change was needed. What to know if you repeat it:

  • The released wallet only talks to verifiers on the EU test list of access-certificate CAs. A self-made access certificate does not work with it. The EU's Testing Relying Party Registration service (https://registry.serviceproviders.eudiw.dev) issues test access certificates under that list.
  • x509_san_dns was not tested: the test registrar's certificates have no dNSName. Use x509_hash (the default) with them.
  • Declining sends nothing. When the user taps Cancel, the reference wallet posts no error response, so the page waits until the transaction expires.
  • Captured presentations are test vectors. Four of them (both formats, both flows), with their request objects and the verifier's per-transaction decryption keys, are verified offline on every build with the production code, including mdoc DeviceAuth over the real OpenID4VP SessionTranscript and the SD-JWT VC Key Binding JWT.

No national wallet has been tested yet. The full record, versions and steps are available to licensed customers from KubeDNA B.V. ([email protected]) on request.

OpenID Foundation conformance suite run

The verifier was run against the OpenID Foundation conformance suite (version 5.3.3, revision f0133e9) on 2026-10-15, with the suite playing the wallet. This is a self-run of the open-source suite, not an OpenID Certification; certification is granted by the OpenID Foundation and was not requested.

Covered: the HAIP plan (oid4vp-1final-verifier-haip-test-plan) and the plain OpenID4VP 1.0 plan; dc+sd-jwt and mso_mdoc; x509_hash and x509_san_dns; direct_post.jwt (and direct_post in dev); request_uri with GET and POST; same-device and cross-device. Every positive test signed the linked account in, and every negative test was refused by the check it targets.

Result of the re-run after the fixes: 32 pass, 30 pass (review), 2 fail, 4 skipped, 16 with a warning. "Pass (review)" means every check passed and the suite waits for a person to look at the uploaded evidence. The two failures and the warnings are justified in the record:

  • the HAIP cross-device happy flow fails a check that a redirect_uri is returned to the wallet's POST, which HAIP §5.1 requires only for same-device logins;
  • the warnings come from the suite's nonce-entropy heuristic, which flags 128-bit base64url nonces; an issue for the suite is drafted.

The first run also failed request-uri-fetched-twice; that was fixed (request objects can now be fetched again until the response). Results per plan and module, logs and the harness to repeat the run are available to licensed customers from KubeDNA B.V. ([email protected]) on request.

When a realm is deleted

Helix has no realm-delete operation in the console or the admin API. If a realm's realm_config row is removed (for example by operator SQL), a database trigger (Flyway V85) deletes that realm's wallet pepper, verifier access keys, in-flight wallet transactions and trusted list sources in the same transaction, so a realm created again under the same id starts with none of them instead of inheriting the old realm's pepper and access key. Wallet account links are removed with the linked user, not with the realm.

Limitations

  • Beta (core verifier), Preview (Digital Credentials API, verify-only), as above. The OpenID Foundation suite run was a self-run, not a certification.
  • No national wallet tested yet. Only the EU reference wallet (test issuer, emulator), with the QR content passed by intent rather than the camera, and without x509_san_dns.
  • Digital Credentials API not yet run against a real wallet.
  • Trusted lists: signer rotation (the LOTL pivot) and LoTE pointers are tested with generated lists only, sources are not exported or imported with a realm, and no production EU list of PID providers is published yet. See Trusted lists.
  • Other credentials always come with the PID: every accepted combination must include it. OpenID4VP has no proof that credentials bound to different keys are in one wallet. See Credentials beyond the PID.
  • mdoc deviceMac is refused; only deviceSignature holder binding is accepted.
  • No HSM or KMS for the verifier access key.
  • One level of assurance: a wallet login (or verification) always asserts http://eidas.europa.eu/LoA/high.
  • Verify-only: no verified_claims for PAR clients, no realm-wide default step-up lifetime, and a password-first login needs the flow engine. See Verify-only.
  • A user who walks away from a post-login step-up leaves no audit event for that login.

Try it: the sandbox demo

HelixIAM's sandbox relying party has a walkthrough that runs the whole flow on your machine: both credential formats, cross-device QR, same-device, and linking a wallet to an existing account. It seeds a wallet-demo realm and stands in for a phone with a test-wallet CLI. The sandbox and the test wallet are available to licensed customers from KubeDNA B.V. ([email protected]) on request.

Development and test only

The test wallet, its CA and every key it writes are throwaway material. They prove nothing about real-world identity and must never be imported into or trusted by a production deployment.

See also