OID4VP explained
OID4VP ("OpenID for Verifiable Presentations") is the verification half
of the loop. A wallet shows a credential to a verifier, the verifier
checks it. Like OID4VCI, it sits on top of OAuth and reuses its
vocabulary — client_id, nonce, state, response_type.
The flow
verifier wallet
──────── ──────
1. mint request constructs authz_req
{ response_type=vp_token,
client_id=x509_san_dns:my-bank.com,
nonce=<random>, state=<random>,
dcql_query=<the query> }
2. signed JAR wraps authz_req in a JWT (RFC 9101),
signs with the verifier's X.509 cert,
exposes at /request.jwt
3. deep link openid4vp://?request_uri=…&client_id=… → scan QR / link
4. fetch + verify ←──── GET /request.jwt
wallet verifies signature against x5c,
user reviews "DATA SHARING REQUEST"
5. present POST /response ←
vp_token={...} (DCQL: keyed by query id,
legacy PE: array)
state=<echoed>
6. verifier checks
- signature on the SD-JWT-VC
- holder binding (KB-JWT cnf vs jwk)
- audience match (verifier's client_id)
- nonce match (replay protection)
- hash binding for selectively-disclosed claims
- status (Token Status List)
The query: DCQL vs Presentation Exchange
A verifier doesn't just ask "any credential" — it specifies what claims it needs. Two query languages exist:
- DCQL (Digital Credentials Query Language). Compact JSON, native to OID4VP 2.0. The EU reference wallet uses this.
- DIF Presentation Exchange v2. JSON-Schema-based, predates DCQL. Still common in the wild.
A DCQL query for "I need a PID with given_name + family_name + birth_date":
{
"credentials": [{
"id": "pid",
"format": "dc+sd-jwt",
"meta": { "vct_values": ["urn:eudi:pid:1"] },
"claims": [
{ "path": ["given_name"] },
{ "path": ["family_name"] },
{ "path": ["birth_date"] }
]
}]
}
The wallet matches credentials in its store against this query, lets
the user pick which one to present, and replies with vp_token keyed
by query id:
{
"vp_token": { "pid": "<sd-jwt-vc>~<disclosure>~<disclosure>~<kb-jwt>" }
}
Signed JAR + x509_san_dns
The verifier wraps its authorization request in a JWT signed with an
X.509 certificate (RFC 9101). The cert's subjectAltName.dNSName extension
identifies the verifier — client_id_prefix=x509_san_dns:my-bank.com
means "trust me iff my cert has my-bank.com as a SAN".
This is what lets a verifier onboard without registering with every
wallet upfront. As long as the cert chain validates and the SAN
matches the client_id, the wallet will engage.
For SDK code, @gramota/oid4vp
provides:
signAuthorizationRequest({ request, cert })— builds the JAR. ReturnsPromise(the compact-serialised JWS).generateSigningCert({ sanDns, … })— mints a self-signed cert for dev (production uses a real CA-issued cert).signingCertToJwks(cert)— for publishing the verifier's keys at/.well-known/jwks.json.
Audience subtlety
The KB-JWT's aud claim binds the presentation to a specific verifier.
Two formats exist in the wild:
- URL form —
aud: "https://my-bank.com"(older convention). x509_san_dns:form —aud: "x509_san_dns:my-bank.com"(used by the EU reference wallet forx509_san_dnsclient_id_prefix).
Configure your verifier to accept both via
additionalAudiences in the Verifier
config. Production wallets in the wild send the latter.
Reading the spec
OID4VP Final 1.0.
§5 is the request, §6 is the response, §A.3.1.1 is the x509_san_dns
prefix. DCQL is in its own draft.