Skip to content

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

  1. A verify-only wallet connection (verify-only, subject.strategy=NONE) that requests dc+sd-jwt, with transactionData.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.
  2. 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

  1. The wallet step sends, per detail, one OpenID4VP transaction_data string: 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.
  2. The wallet's key-binding JWT must carry transaction_data_hashes_alg: "sha-256" and transaction_data_hashes equal to the SHA-256 of each string, in order. Missing, different, another algorithm, extra hashes, or hashes in a credential no credential_ids names: refused (TRANSACTION_DATA_MISMATCH). A presentation made for other transaction data fails the hash check as well as the nonce.
  3. 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.additionalKeyRef to 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 except client_id and verifier_info in the payload (including expected_origins), and per signature a protected header with alg, typ=oauth-authz-req+jwt, its own x5c and its own client_id (the access key's as configured; the second key's x509_hash: of its certificate), plus the verifier_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_supported discovery 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