Skip to main content

OpenID4VP presentation explained: how a verifier receives a credential

OpenID4VP, OpenID for Verifiable Presentations, defines how a verifier asks a wallet for credentials the holder already has, and how the wallet answers. It is the mirror image of OpenID4VCI issuance: issuance puts a credential into a wallet, presentation gets a proof out of it. This page walks through a single presentation from the request the verifier builds to the last check it runs on the response.

What a presentation has to bind together

A credential sitting in a wallet is only evidence if the verifier can tie it to this request, made by this verifier, right now, by the holder who owns it. OpenID4VP exists to bind those four things together in a way that survives being carried over a QR code or a browser API.

Leave any one of them out and the protocol stops proving what it looks like it proves: without the nonce an old response can be replayed, without the client identifier a response captured by one verifier can be forwarded to another, and without the query the wallet has no portable way to know what to release.

The authorization request

The request is an OAuth 2.0 authorization request with OpenID4VP parameters on top. These are the ones that matter in practice.

ParameterWhat it does
client_idIdentifies the verifier, prefixed with a Client Identifier Prefix that tells the wallet how to authenticate it
response_typevp_token for a presentation
response_modeHow the answer comes back: fragment, direct_post, direct_post.jwt, dc_api or dc_api.jwt
nonceRequired. A fresh random value the wallet must sign over, so the response cannot be replayed
stateOpaque value echoed back, used to match the response to the session
dcql_queryThe query saying which credentials and claims are wanted
client_metadataThe verifier's own metadata: jwks, encrypted_response_enc_values_supported, vp_formats_supported
response_uriWhere the wallet POSTs the response, for direct_post and direct_post.jwt
request_uriWhere the wallet fetches the Request Object, instead of the parameters being sent inline
request_uri_methodget or post, how the wallet retrieves that Request Object
transaction_dataBase64url-encoded JSON objects describing what the holder is authorizing, for example a payment
verifier_infoAttestations about the verifier itself, for example a registration certificate

Client Identifier Prefixes

A wallet has to decide whether to trust the party asking before it shows the holder anything. The prefix on client_id tells it which mechanism to use. x509_san_dns:verifier.example and redirect_uri:https://verifier.example/cb are two different claims about the same verifier, authenticated in two different ways.

PrefixHow the wallet authenticates the verifier
(none, pre-registered)The verifier is already known to the wallet out of band
redirect_uriThe identifier is the verifier's redirect or response URI; the request is unsigned
x509_san_dnsA DNS name in the SAN of the X.509 certificate that signed the request
x509_hashThe base64url SHA-256 hash of the leaf certificate that signed the request
openid_federationAn OpenID Federation entity identifier, resolved through a trust chain
decentralized_identifierA DID whose document holds the key that signed the request
verifier_attestationA JWT attestation, issued by a party the wallet trusts, bound to the signing key
originReserved for the Digital Credentials API. A verifier never sends it: a wallet must not accept it in any request. The wallet itself forms origin:<origin> from the origin the browser reports, and uses that as the audience

By value and by reference

The parameters can travel inline in the URL, or the URL can carry only a request_uri the wallet fetches. Anything that has to fit in a QR code uses the second form: a signed Request Object can easily be several kilobytes, which a QR code scanned across a counter cannot carry reliably.

request_uri_method=post is worth calling out. With get, the verifier has to build one Request Object that every wallet can read, guessing at formats and algorithms. With post the wallet goes first: it POSTs its wallet_metadata and a wallet_nonce to the request_uri, and the verifier answers with a Request Object tailored to that wallet's capabilities and signed over that nonce. The wallet then checks its own wallet_nonce came back, which proves the Request Object was minted for this exchange rather than replayed.

What to ask for: the DCQL query

dcql_query is where the verifier says what it needs. It names one credential query per credential, each pinned to a format and a list of claim paths, so the wallet can match it against what the holder actually holds without a vendor-specific integration.

{
"credentials": [
{
"id": "company_registration",
"format": "dc+sd-jwt",
"meta": { "vct_values": ["urn:eudi:business:company-registration:1"] },
"claims": [
{ "id": "reg_no", "path": ["registration_number"] },
{ "id": "legal_name", "path": ["legal_name"] }
]
}
]
}

The id of each credential query matters beyond the request: it is the key the wallet uses in the vp_token it sends back. DCQL also covers fallbacks, alternative combinations of credentials, value matching and issuer restrictions, which are covered in full in DCQL explained.

Response modes

The response mode decides where the vp_token goes and whether anyone in between can read it.

ModeHow the answer travelsWhen it is used
fragmentIn the fragment of the verifier's redirect URISame-device browser redirects only; the default for vp_token, but the response passes through the browser
direct_postHTTPS POST from the wallet straight to response_uriCross-device flows; the response never touches the browser
direct_post.jwtThe same POST, but the response is a JWE encrypted to the verifier's key from client_metadata.jwksAnywhere the claims must not be readable by the endpoint's infrastructure; required by HAIP
dc_apiReturned through the browser's Digital Credentials API to the calling pageSame-device web, with the browser mediating
dc_api.jwtThe same, encrypted to the verifier's keyThe Digital Credentials API under HAIP

With direct_post and direct_post.jwt the wallet POSTs to the response_uri out of band, and the endpoint answers with a small JSON object. On a same-device flow that object carries a redirect_uri holding a single-use response_code, which the wallet opens in the browser so the verifier's page can pick the session back up. The credential data itself never goes through the browser, only that code.

HTTP/1.1 200 OK
Content-Type: application/json

{
"redirect_uri": "https://verifier.example/cb?response_code=8a1f0c4e-9d2b"
}

Same-device, cross-device, and the Digital Credentials API

The protocol is the same in all three cases. What changes is how the wallet gets invoked and, with it, what the verifier can assume about who it is talking to.

The Digital Credentials API closes a gap the other two cannot. With a link or a QR code, any app that registered the scheme can claim the request, and the wallet has only the request itself to judge who sent it. Under the browser API the origin of the calling page is supplied by the browser and cannot be forged by the page. An unsigned request therefore carries no client_id at all: the wallet takes the origin the browser reports, forms origin:<origin> from it, and uses that as the audience of the presentation, so the request is authenticated by that origin alone. A signed request does carry a client_id, plus expected_origins, so the wallet can check the origin the browser reports against the list the verifier signed over. A wallet must never accept an origin: client identifier sent in a request, from the browser API or anywhere else, because that identifier is something only the wallet may derive.

Worked example: opening a trade account

A wholesale distributor opens trade accounts online. Before granting credit it needs to see that the applicant is a registered company, and it wants the registration number and legal name, nothing more. The applicant is at a desktop browser with their business wallet on a phone, so this is a cross-device flow with direct_post.jwt.

1. The verifier creates the request

The distributor's backend mints a nonce and a state, generates an ephemeral encryption key pair for this one request, and opens a request_uri endpoint for this session. Because request_uri_method is post, the Request Object itself is not built yet: it is minted in step 2, once the wallet has said what it supports and supplied a wallet_nonce for the verifier to sign over. The front end renders the short URI below as a QR code.

Verifier

Shows a QR code with an authorization request URI

Carries only the client identifier and where to fetch the real request

openid4vp://authorize
?client_id=x509_san_dns%3Averifier.wholesale.example
&request_uri=https%3A%2F%2Fverifier.wholesale.example%2Foid4vp%2Freq%2F7f3c9a
&request_uri_method=post

2. The wallet fetches the Request Object

The wallet POSTs its capabilities and a fresh wallet_nonce to the request_uri, and gets back a signed Request Object. It checks the signature against the certificate in the x5c header, checks that the certificate's SAN really contains verifier.wholesale.example so the x509_san_dns client identifier holds up, and checks its own wallet_nonce came back.

Wallet

POST /oid4vp/req/7f3c9a

Sends wallet_metadata and wallet_nonce, receives a signed Request Object

Request Object payload (the JWT is signed with typ: oauth-authz-req+jwt)

{
"iss": "x509_san_dns:verifier.wholesale.example",
"aud": "https://self-issued.me/v2",
"client_id": "x509_san_dns:verifier.wholesale.example",
"response_type": "vp_token",
"response_mode": "direct_post.jwt",
"response_uri": "https://verifier.wholesale.example/oid4vp/response/7f3c9a",
"nonce": "n-4c81d0f2a7",
"state": "trade-account-88213",
"wallet_nonce": "w-2f9b71",
"dcql_query": {
"credentials": [
{
"id": "company_registration",
"format": "dc+sd-jwt",
"meta": { "vct_values": ["urn:eudi:business:company-registration:1"] },
"claims": [
{ "id": "reg_no", "path": ["registration_number"] },
{ "id": "legal_name", "path": ["legal_name"] }
]
}
]
},
"client_metadata": {
"jwks": {
"keys": [
{ "kty": "EC", "crv": "P-256", "use": "enc", "alg": "ECDH-ES", "kid": "eph-7f3c9a", "x": "...", "y": "..." }
]
},
"encrypted_response_enc_values_supported": ["A256GCM", "A128GCM"],
"vp_formats_supported": { "dc+sd-jwt": { "sd-jwt_alg_values": ["ES256"] } }
}
}

3. The holder consents

The wallet runs the DCQL query over the credentials it holds, finds one company registration credential that matches the vct, and asks the holder to approve releasing exactly the two requested claims. It shows the verifier as verifier.wholesale.example, which it can state because the certificate backed that name up in step 2. The holder approves.

4. The wallet builds and encrypts the response

For dc+sd-jwt a presentation is the issuer-signed JWT, one disclosure per released claim, and a Key Binding JWT signed with the holder's private key. That Key Binding JWT is what makes the presentation non-transferable.

Key Binding JWT (typ: kb+jwt)

{
"aud": "x509_san_dns:verifier.wholesale.example",
"nonce": "n-4c81d0f2a7",
"iat": 1789376400,
"sd_hash": "3Qo9...mJk"
}
  • aud: the verifier's client identifier, so this presentation is worthless at any other verifier
  • nonce: the nonce from the request, so it is worthless on any other request
  • iat: when the holder signed, which lets the verifier reject a stale presentation
  • sd_hash: a hash over the issuer-signed JWT and the exact disclosures being released, so nothing can be added or removed after signing

The wallet assembles the vp_token, encrypts the whole authorization response to the verifier's ephemeral public key with ECDH-ES and A256GCM, and POSTs the resulting JWE.

Wallet

POST /oid4vp/response/7f3c9a

Sends the encrypted authorization response; only the holder of the ephemeral private key can read the claims

POST /oid4vp/response/7f3c9a HTTP/1.1
Host: verifier.wholesale.example
Content-Type: application/x-www-form-urlencoded

response=eyJhbGciOiJFQ0RILUVTIiwiZW5jIjoiQTI1NkdDTSIsImtpZCI6ImVwaC03ZjNjOWEifQ...

5. What the verifier decrypts

The vp_token is a JSON object keyed by the credential query ids from the DCQL query. Each value is an array of presentations, because one query can legitimately match more than one credential.

{
"vp_token": {
"company_registration": [
"<issuer-signed JWT>~<disclosure: registration_number>~<disclosure: legal_name>~<key binding JWT>"
]
},
"state": "trade-account-88213"
}

After validation the distributor's backend holds exactly two claims, and nothing else the credential contains:

{
"vct": "urn:eudi:business:company-registration:1",
"registration_number": "12345678",
"legal_name": "Example Logistics B.V."
}

The whole flow at a glance

Solid arrows are requests, dashed arrows are responses.

This is the cross-device shape: the browser and the wallet never meet, so the browser polls the verifier for the outcome. On a same-device flow the last three steps are different — the verifier answers the wallet with a redirect_uri carrying a response_code, and the wallet opens it so the browser lands back on the verifier's page with that code.

Validating the vp_token

Receiving a vp_token proves nothing on its own. These are the checks that turn it into evidence, in the order they are worth running: each one is cheap relative to the next, and each one can fail on its own.

Three of these deserve a note.

Holder binding. Steps 2 and 5 together are what stop a presentation being copied. The issuer embedded the holder's public key in the credential at issuance, the holder signs the Key Binding JWT with the matching private key, and that signature covers the verifier's client_id and the request's nonce. A presentation lifted from one session and replayed at another verifier fails on aud; replayed at the same verifier later it fails on nonce. For mso_mdoc the same job is done by DeviceAuth, signed over a SessionTranscript built from an OpenID4VPHandover that covers the client identifier, the nonce, the thumbprint of the verifier's response encryption key and the response URI.

Trust. A valid issuer signature only says the credential came from the key it claims to come from. Step 4 is what says that key belongs to an issuer this verifier is willing to believe for this credential type. Under the EUDI Wallet that list comes from the Trusted Lists maintained under the eIDAS framework; in the Business Wallet it is configured per verifier template.

Revocation. A credential can be signed, bound, unexpired and still withdrawn. Step 8 resolves the status claim to a Status List Token and reads the bit for this credential; see Status List explained for how that lookup works and why it does not tell the issuer who is checking.

HAIP: what the EUDI Wallet requires on top

Base OpenID4VP leaves a lot open: unsigned requests are allowed, responses need not be encrypted, six client identifier prefixes are possible alongside pre-registered clients, and several credential formats and algorithms are in play. The OpenID4VC High Assurance Interoperability Profile closes those choices down so that any conforming wallet and any conforming verifier interoperate, and so the weak options are off the table. These are the requirements that bear on presentation.

AreaWhat HAIP requires
Request integritySigned authorization requests using JAR, passed by reference with request_uri
Verifier identificationThe x509_hash Client Identifier Prefix for signed requests. The signing certificate must not be self-signed, and the trust anchor's certificate must not be in the x5c header
Query languageDCQL, query and response, as defined in OpenID4VP Section 6
Response modedirect_post.jwt for redirect-based flows, dc_api.jwt for the Digital Credentials API, so every response is encrypted
Response encryptionJWE alg ECDH-ES with P-256. Verifiers support both A128GCM and A256GCM; wallets support A128GCM or A256GCM, or both. The verifier supplies a fresh ephemeral public key with every request
Signature algorithmES256 at minimum, for presentations and for status information
Credential formatsAt least one of dc+sd-jwt or mso_mdoc
Wallet invocationSupport for the W3C Digital Credentials API, or an equivalent platform API

Read the table as a floor, not a ceiling: a deployment can require more, but a verifier that drops below any line here is not HAIP-conformant and an EUDI Wallet may refuse it.

Doing this with the Credenco verification API

The Business Wallet runs the verifier side of this protocol for you. You describe what you want once as a verifier template, then create one authorization request per verification and read the result; the wallet handles the Request Object, the encryption keys, the vp_token and every check in the validation section above.

1. Define what you want once

A verifier template, shown in the UI as a Receive credential configuration, holds the credential types, the claims and the trust rules for one verification use case. See How to receive credentials and Create Verifier Template. Its template key is what you pass as query_id.

2. Create an authorization request

Create authorization request takes the protocol choices described on this page as plain fields:

curl -X POST https://your-wallet.credenco.com/api/v1/oid4vp/backend/auth/requests \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '{
"correlation_id": "trade-account-88213",
"query_id": "company_registration",
"response_mode": "direct_post.jwt",
"request_uri_method": "POST",
"qr_code": { "size": 400 },
"callback": {
"url": "https://your-backend.example/webhooks/verification",
"status": ["authorization_response_verified", "error"],
"verified_data": ["credential_claims_deserialized"]
}
}'
FieldMaps to
query_idThe verifier template key, which supplies the dcql_query
correlation_idYour own business key for the session; a UUID is assigned if you omit it
response_modedirect_post or direct_post.jwt, as described above. Defaults to direct_post
request_uri_methodGET or POST, how the wallet retrieves the Request Object
request_uri_baseThe scheme in the returned URI. Defaults to oid4vp://; set an https:// base for web or organizational wallets
client_idThe client identifier and prefix to present to the wallet
transaction_dataBase64url-encoded JSON describing what the holder is authorizing
direct_post_response_redirect_uriWhere the wallet should send the holder after a successful same-device interaction
qr_codeAsk for a ready-to-display QR code image of the request URI in the response
callbackGet pushed status updates instead of polling

The response gives you request_uri to put in front of the holder, status_uri to poll, and qr_uri if you asked for a QR code.

3. Collect the result

Poll Get authorization session status, or let the callback push it to you. The status values follow the protocol steps:

StatusMeaning
authorization_request_createdThe request exists and is waiting for a wallet
authorization_request_retrievedA wallet has fetched the Request Object
authorization_response_receivedA response arrived and is being validated
authorization_response_verifiedEvery check passed; verified_data carries the claims
errorValidation failed, or the flow could not complete; error carries the detail

Use Terminate authorization request to cancel a session the holder abandoned. API access is set up as described in API authentication.

A worked, end-to-end integration using these endpoints is in the Verify BIG Registration use case.

  • OpenID4VP
  • DCQL
  • Verifier
  • Relying Party
  • Holder
  • vp_token
  • Key Binding
  • SD-JWT VC
  • Digital Credentials API

Frequently asked questions

What is the difference between OpenID4VP and OpenID4VCI?

They are two halves of the same credential lifecycle. OpenID4VCI is how a credential gets into a wallet: the issuer makes an offer, the wallet authorizes, proves it holds a key, and receives a signed credential. OpenID4VP is how a proof comes back out: a verifier asks for specific claims, the holder consents, and the wallet returns a presentation bound to that one request. The issuer is not a party to the presentation: it is not called into the flow, it does not authorize it, and it is never told which verifier saw what. The verifier does still fetch public artefacts the issuer publishes — its signing keys and the status list — but those are cacheable documents that cover many credentials at once and say nothing about this particular transaction.

Why does OpenID4VP need a nonce when the credential is already signed?

The issuer's signature proves the credential is genuine. It says nothing about when this particular copy was shown, or to whom. Without a nonce, anyone who captured a presentation once could send the same bytes again and it would still verify. The verifier generates a fresh nonce per request, the holder signs over it, and a presentation carrying the wrong nonce is rejected. That is what makes a presentation a live act rather than a reusable file.

When should a verifier use direct_post.jwt rather than direct_post?

Whenever the claims should not be readable anywhere between the wallet and the code that consumes them. With direct_post the response arrives as plain form parameters at the response_uri, so any load balancer, WAF or reverse proxy in front of it handles the claims in the clear and may log them. With direct_post.jwt the wallet encrypts to an ephemeral public key the verifier put in client_metadata, so only the backend holding the matching private key can read them. HAIP requires it, and for personal data it is the sensible default regardless.

Does the Digital Credentials API replace QR codes?

No, it solves a different case. The browser API only helps when the holder is on the same device as the wallet and in a browser that supports it, and its advantage is that the browser vouches for the origin of the requesting page, which no QR code can do. Cross-device flows, where the holder is at a desktop with the wallet on their phone or at a physical counter, still need a QR code and direct_post. A verifier that wants to reach everyone supports both.

What stops someone presenting a credential that is not theirs?

Holder binding. At issuance the issuer embeds the holder's public key in the credential, and at presentation the wallet has to sign with the matching private key: a Key Binding JWT for dc+sd-jwt, DeviceAuth for mso_mdoc. That signature covers the verifier's client identifier and the request's nonce, so it is only valid for this verifier and this request. Someone who copies the credential bytes without the private key cannot produce it, and a verifier that checks step 5 of the validation list will reject them.

Can a verifier ask for more than one credential in a single request?

Yes. A DCQL query can carry several credential queries, and credential_sets can say which combinations are acceptable, for example a company registration plus either a VAT registration or a tax statement. The wallet resolves that in one consent screen and answers with one vp_token whose keys are the credential query ids. The holder approves once and the verifier makes one round trip, instead of several sequential requests.

Sources

  1. OpenID for Verifiable Presentations 1.0
  2. OpenID4VC High Assurance Interoperability Profile 1.0
  3. SD-JWT-based Verifiable Credentials
  4. W3C Digital Credentials API
  5. EUDI Wallet Architecture and Reference Framework
note

This page is informational and does not constitute legal advice. For authoritative guidance consult the OpenID Foundation and the European Commission directly.