Transaction confirmation with the wallet¶
An application can have a signed-in user confirm one transaction — a consent text, a payment — with their EU Digital Identity Wallet. The wallet shows the transaction and the user's wallet key signs a hash of exactly what was shown (OpenID4VP 1.0 transaction_data). Helix checks the hash and tells the application in the ID token. This page also covers two request features that came with it: verifier_info and multi-signed Digital Credentials API requests. (Preview, not yet tested with a real national wallet.)
Status: Preview — not yet tested with a real national wallet
New in 1.1.0-rc.1. Covered by an end-to-end test with the project's own test wallet and by unit tests; not run against a real wallet. Only wallets that implement the Helix transaction types below can confirm: a wallet must refuse a type it does not know, so any other wallet declines.
Setting it up¶
- A verify-only wallet connection (verify-only,
subject.strategy=NONE) that requestsdc+sd-jwt, withtransactionData.types: the types it may confirm. Console: the connection's Policies → Transaction confirmation, with a preview of what the wallet shows. Terraform:transaction_data_types. - The application's login flow contains the EU Digital Identity Wallet verification step (
eudi-wallet-verify) with that connection, as a top-level REQUIRED step.
"config": {
"subject.strategy": "NONE",
"verify.linkedProvider": "eudi-wallet",
"credentialFormats": "[\"dc+sd-jwt\"]",
"transactionData.types": "[\"urn:helixiam:transaction-data:consent-text:v1\",\"urn:helixiam:transaction-data:payment:v1\"]"
}
The authorization_details types¶
OpenID4VP leaves type identifiers to ecosystems. The EUDI payment ("SCA") transaction types were not final, so Helix defines its own, clearly named types:
| Type | Fields (all strings) |
|---|---|
urn:helixiam:transaction-data:consent-text:v1 |
consent_text (required, at most 2000 characters, newlines allowed), locale (optional), reference (optional, at most 128) |
urn:helixiam:transaction-data:payment:v1 |
payee (required, at most 140), amount (required, a decimal such as 125.00, up to 4 decimals), currency (required, ISO 4217 code such as EUR), reference (optional), description (optional, at most 500) |
No other members, and no control or invisible formatting characters: what the wallet shows must be what the user reads.
How an application asks¶
An ordinary authorization request with RFC 9396 authorization_details (shown unencoded; URL-encode the JSON):
GET /realms/acme/oauth2/authorize?response_type=code&client_id=bank&redirect_uri=…&scope=openid&state=…&nonce=…
&code_challenge=…&code_challenge_method=S256
&authorization_details=[{"type":"urn:helixiam:transaction-data:consent-text:v1",
"consent_text":"I agree to loan agreement L-1234.","reference":"L-1234"}]
Refused up front, before any sign-in, with 400 invalid_authorization_details:
- invalid details: 1–5 entries, Helix types only, no mix with
openid_credential; - a client whose flow has no top-level REQUIRED wallet verification step whose connection allows every requested type. An ALTERNATIVE step, one in a sub-flow or a DISABLED one may never run, so it does not count;
prompt=none;- the PAR endpoint (the step reads front-channel parameters only).
The check runs again when the request resumes after sign-in. Such a request always runs the client's flow, once per request, even with an SSO session: a session never confirms a transaction by itself.
Safety net. Should a code still be issued for such a request without a completed confirmation bound to it, it is stored invalidated and the token endpoint refuses it (invalid_grant).
The wallet step requirement¶
- The wallet step sends, per detail, one OpenID4VP
transaction_datastring: base64url of{"type", "credential_ids": ["pid_sd_jwt"], "transaction_data_hashes_alg": ["sha-256"], <fields>}. The strings are stored on the wallet transaction, encrypted. The DCQL query then asks for the SD-JWT VC PID only. - The wallet's key-binding JWT must carry
transaction_data_hashes_alg: "sha-256"andtransaction_data_hashesequal to the SHA-256 of each string, in order. Missing, different, another algorithm, extra hashes, or hashes in a credential nocredential_idsnames: refused (TRANSACTION_DATA_MISMATCH). A presentation made for other transaction data fails the hash check as well as the nonce. - The step also checks the usual verify-only binding (the wallet must be linked to this user) and that the confirmed strings are exactly the ones made from this request's
authorization_details.
mdoc is not supported for confirmations. OpenID4VP leaves the mdoc binding to each transaction type and document type; the Helix types define none. So confirmations use the SD-JWT VC PID only, and a connection without dc+sd-jwt cannot allow types (400 at save).
The token response¶
For the code of that very request (bound to its client and a hash of client_id, state, nonce, redirect_uri and the authorization_details value; 5 minutes), the ID token of the code exchange carries:
"transaction_confirmation": {
"authorization_details": [{"type": "urn:helixiam:transaction-data:consent-text:v1", "consent_text": "…", "reference": "L-1234"}],
"transaction_data": ["eyJ0eXBlIjoi…"],
"transaction_data_hashes": ["gpwEspYbnuwN…"],
"transaction_data_hashes_alg": "sha-256",
"credential_format": "dc+sd-jwt",
"time": "2026-10-19T10:00:00Z"
}
The token response of the code exchange also repeats the confirmed authorization_details (RFC 9396 §7).
No claim means not confirmed
The application must check transaction_confirmation in the ID token, as it checks acr. The confirmation is reported once: after the code exchange it is removed from the stored authorization, and a refresh carries neither the claim nor authorization_details, so a refresh is never a second confirmation.
Audit. WALLET_TRANSACTION_CONFIRMED (actor: the user) with transactionId, alias, client, types, hashes and alg — never a value (no consent text, amount or payee). Refusals are WALLET_VERIFY_FAILURE with reason=TRANSACTION_DATA_MISMATCH.
verifier_info¶
OpenID4VP §5.1 verifier_info carries attestations about the verifier to the wallet. Per wallet connection, verifierInfo.source (console: Policies → Verifier information; Terraform verifier_info_source, verifier_info_format, verifier_info_json):
verifierInfo.source |
What is sent |
|---|---|
none (default) |
nothing |
registration_certificate |
one entry {"format": verifierInfo.format, "data": <base64 DER of the first certificate of the connection's registration certificate>}. The format identifier is yours to set, because the ARF identifier for registration certificates was not final |
custom |
verifierInfo.json: an array of 1–5 {format, data, credential_ids?} objects (format without spaces, data a non-empty string or object, credential_ids naming this connection's credential queries), at most 16 KiB. Otherwise opaque: the wallet validates attestations, Helix only their shape |
It is sent in every request of the connection: the request object of the QR and same-device flows, the signed and unsigned Digital Credentials API requests, and each signature's protected header of a multi-signed one. Per request, an entry's credential_ids keep only the credential queries that request asks for (a transaction confirmation asks for the SD-JWT PID only, so the mdoc PID query drops out), and an entry left with none is not sent.
Multi-signed Digital Credentials API requests¶
For the W3C Digital Credentials API, a connection can send a request signed by two keys (dcApi.request=multisigned, protocol openid4vp-v1-multisigned, OpenID4VP A.3.2.2; console: Policies → Digital Credentials API → Multi-signed; Terraform dc_api_request = "multisigned" and dc_api_additional_key_ref):
- Set
dcApi.additionalKeyRefto a second key imported into the realm, with its own certificate chain (not the access key); it is checked at save like the access key. - The request is
{"request": {"payload", "signatures"}}, the JWS JSON Serialization: every parameter exceptclient_idandverifier_infoin the payload (includingexpected_origins), and per signature a protected header withalg,typ=oauth-authz-req+jwt, its ownx5cand its ownclient_id(the access key's as configured; the second key'sx509_hash:of its certificate), plus theverifier_info. - A wallet can verify either signature. The response is bound to the origin as for the other request types.
Not in this release¶
- Confirmations for mdoc, for sign-in transactions, through PAR, or through the WYSIWYS transaction-signing API (which signs with an enrolled device key, not the wallet).
- The EUDI payment (SCA) transaction types.
- The
authorization_details_types_supporteddiscovery entry. - ISO 18013-7 Annex C (
org-iso-mdoc) over the Digital Credentials API: it needs an HPKE-encrypted request and response and the Annex C session transcript, which must match byte for byte, and the ISO text and test vectors were not available to check an implementation against. It is deferred rather than shipped half-built. mdoc over the Digital Credentials API keeps working through OpenID4VP.
See also¶
- EU Digital Identity Wallet (OpenID4VP) — verify-only connections and the Digital Credentials API
- Authentication flows — where the
eudi-wallet-verifystep goes - Terraform provider