OID4VP¶
Level: Beginner protocol + implementation
Simple explanation¶
OID4VP is the protocol for presenting a credential from a wallet to a verifier.
What you will learn¶
- The request-response flow for credential presentation
- How OID4VP and OID4VCI differ (presentation vs issuance)
- How DCQL and Presentation Exchange query languages work
- What
SdJwt.Net.Oid4Vphandles vs what your application must implement
If OID4VCI is the process of receiving a digital passport, OID4VP is the process of showing only the necessary page at a border checkpoint.
OID4VCI vs OID4VP¶
| Protocol | Direction | Plain English |
|---|---|---|
| OID4VCI | Issuer to Wallet | "Put this credential into my wallet." |
| OID4VP | Wallet to Verifier | "Prove something from my wallet." |
Where it fits¶
OID4VP is used only for presentation. It is not used when a wallet receives a credential from an issuer. That is OID4VCI.
| Audience | Developers building verifier services or wallet presentation flows, and architects designing cross-device verification. |
| Purpose | Explain how verifiers request credentials and wallets respond with verifiable presentations using the OpenID for Verifiable Presentations protocol, with working SdJwt.Net.Oid4Vp code examples. |
| Scope | Authorization request/response structure, DCQL and Presentation Exchange query mechanisms, direct post transport, multi-credential responses, optional SIOPv2 id_token responses, and three-layer validation (protocol, cryptographic, binding). Out of scope: issuance (see OID4VCI), PEX internals (see Presentation Exchange). |
| Success criteria | Reader can build a verifier authorization request, process wallet responses with VpTokenValidator, and implement wallet-side response construction with key binding. |
Prerequisites¶
Before reading this document, you should understand:
| Prerequisite | Why Needed | Resource |
|---|---|---|
| SD-JWT basics | OID4VP transports SD-JWT presentations | SD-JWT |
| Verifiable Credentials | OID4VP presents SD-JWT VCs | VC |
| Key Binding JWT | Required for credential holder binding | Selective Disclosure Mechanics |
Glossary¶
| Term | Definition |
|---|---|
| Verifier | The party requesting credential presentation (service provider, relying party) |
| Wallet | Application that holds credentials and creates presentations on behalf of the Holder |
| Authorization Request | Message from Verifier specifying what credentials/claims are needed |
| Authorization Response | Message from Wallet containing the presented credentials |
| vp_token | The verifiable presentation payload (SD-JWT with disclosed claims + KB-JWT) |
| Presentation Submission | Mapping that tells the Verifier which credential satisfies which requirement |
| Nonce | Random value for replay protection - binds response to specific request |
| DCQL | Digital Credentials Query Language - lightweight alternative to Presentation Exchange |
| Direct Post | Response mode where Wallet POSTs response to Verifier's endpoint |
Why OID4VP exists¶
A holder has credentials in their wallet. A verifier needs to check specific facts. OID4VP gives the verifier a standard way to ask, the wallet a standard way to answer, and both parties a standard way to confirm the exchange is authentic.
Verifier asks, wallet answers¶
| Verifier needs to know | What the verifier sends | What the wallet reveals |
|---|---|---|
| "Are you over 18?" | DCQL query for age_over_18 |
Only the age_over_18 claim from the ID credential |
| "What is your job title?" | PEX definition for EmploymentCredential with job_title |
Job title and employer, not salary or start date |
| "Do you have a valid driver's license?" | DCQL query for mDL credential type |
License class and expiry, not address or photo |
The holder always chooses which claims to disclose. The verifier can request, but the wallet (and the user) decide.
Three layers of validation¶
Every OID4VP exchange involves three independent layers. Missing any one of them creates a security gap:
| Layer | What it checks | Who is responsible |
|---|---|---|
| Protocol validation | Nonce matches, audience matches, response mode is correct, request is not expired | SdJwt.Net.Oid4Vp (library) |
| Credential validation | Issuer signature is valid, credential is not expired, credential is not revoked, KB-JWT binds to holder | SdJwt.Net.Vc + SdJwt.Net.StatusList (library) |
| Business validation | Issuer is trusted for this credential type, disclosed claims meet business rules, user is authorized | Your application (not the library) |
Passing all three layers means the presentation is technically valid. It does not mean the credential is trustworthy for your business decision -- issuer trust, claim sufficiency, and authorization policy are always your application's responsibility.
Roles and artifacts¶
| Artifact | Purpose |
|---|---|
AuthorizationRequest |
Verifier request (client_id, response_type=vp_token, nonce, query mechanism) |
| Query mechanism | Either dcql_query or presentation_definition / presentation_definition_uri |
AuthorizationResponse |
Wallet response with vp_token and presentation_submission |
vp_token |
One or more presented SD-JWT VC artifacts |
id_token |
Optional SIOPv2 subject-signed ID Token for vp_token id_token responses |
presentation_submission |
Mapping between verifier descriptors and presented tokens |
The complete flow¶
sequenceDiagram
autonumber
participant V as Verifier
participant W as Wallet
participant U as User
Note over V: 1. Build Authorization Request
V->>V: Generate nonce
V->>V: Define requirements (PEX or DCQL)
V-->>W: AuthorizationRequest (via QR, deep link, redirect)
Note over W: 2. Process Request
W->>W: Parse request
W->>W: Find matching credentials
W->>U: Show consent screen
U-->>W: Approve disclosure
Note over W: 3. Build Response
W->>W: Create presentation(s) with KB-JWT
W->>W: Build presentation_submission
W-->>V: AuthorizationResponse (direct_post)
Note over V: 4. Validate Response
V->>V: Validate nonce matches
V->>V: Validate submission mapping
V->>V: Verify each VP token
V->>V: Check KB-JWT audience = client_id
V-->>U: Grant/deny access
Request/response in detail¶
Authorization request example¶
A verifier wanting to verify employment status would send a request like this:
{
"client_id": "https://employer-check.example.com",
"client_id_scheme": "x509_san_uri",
"response_type": "vp_token",
"response_mode": "direct_post",
"response_uri": "https://employer-check.example.com/callback",
"nonce": "n-0S6_WzA2Mj",
"state": "af0ifjsldkj",
"presentation_definition": {
"id": "employment-verification",
"name": "Employment Verification",
"purpose": "Verify current employment status",
"input_descriptors": [
{
"id": "employment_credential",
"format": {
"dc+sd-jwt": {}
},
"constraints": {
"fields": [
{
"path": ["$.vct"],
"filter": {
"type": "string",
"pattern": ".*employment.*"
}
},
{
"path": ["$.position"],
"optional": false
},
{
"path": ["$.department"],
"optional": true
}
]
}
}
]
}
}
Key fields:
| Field | Required | Purpose |
|---|---|---|
client_id |
Yes | Identifies the Verifier; used for KB-JWT audience validation |
response_type |
Yes | Must be vp_token for OID4VP |
response_mode |
No | How response is delivered (direct_post for cross-device) |
response_uri |
Conditional | Where Wallet POSTs response (required for direct_post) |
nonce |
Yes | Binds response to this request (replay protection) |
state |
No | Opaque value returned unchanged (session correlation) |
presentation_definition |
Conditional | PEX query (mutually exclusive with dcql_query) |
Common response modes¶
| Response mode | Transport | Typical scenario |
|---|---|---|
direct_post |
Wallet POSTs to response_uri |
Cross-device (QR code scan) |
fragment |
Response in URL fragment | Same-device browser redirect |
direct_post.jwt |
Encrypted JARM response to response_uri |
High-security cross-device |
Authorization response example¶
The wallet responds with the matching credentials:
{
"vp_token": "eyJ0eXAiOiJkYytzZC1qd3QiLC...~WyJzYWx0MSIsInBvc2l0aW9uIiwiU2VuaW9yIEVuZ2luZWVyIl0~WyJzYWx0MiIsImRlcGFydG1lbnQiLCJFbmdpbmVlcmluZyJd~eyJ0eXAiOiJrYitqd3QiLC...",
"presentation_submission": {
"id": "submission-1",
"definition_id": "employment-verification",
"descriptor_map": [
{
"id": "employment_credential",
"format": "dc+sd-jwt",
"path": "$"
}
]
},
"state": "af0ifjsldkj"
}
Response fields:
| Field | Purpose |
|---|---|
vp_token |
The SD-JWT VC with only requested disclosures + KB-JWT |
presentation_submission |
Maps which credential satisfies which input descriptor |
state |
Echoed back from request for session correlation |
Multiple credentials response¶
When multiple credentials are requested, vp_token becomes an array:
{
"vp_token": [
"eyJ0eXAiOiJkYytzZC1qd3QiLC...~disclosure1~kb-jwt1",
"eyJ0eXAiOiJkYytzZC1qd3QiLC...~disclosure2~kb-jwt2"
],
"presentation_submission": {
"id": "multi-submission",
"definition_id": "background-check",
"descriptor_map": [
{
"id": "employment_input",
"format": "dc+sd-jwt",
"path": "$[0]"
},
{
"id": "education_input",
"format": "dc+sd-jwt",
"path": "$[1]"
}
]
}
}
Advanced: Combined VP Token and SIOPv2 ID Token response¶
When a verifier also needs a self-issued OpenID subject binding, it can request the combined response type:
{
"client_id": "https://verifier.example.com",
"response_type": "vp_token id_token",
"scope": "openid",
"id_token_type": "subject_signed_id_token",
"nonce": "xyz789",
"presentation_definition": {
"id": "identity-proof",
"input_descriptors": []
}
}
The wallet response includes both vp_token and id_token:
{
"vp_token": "eyJ0eXAiOiJkYytzZC1qd3QiLC...~disclosure~kb-jwt",
"id_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9...",
"presentation_submission": {
"id": "submission-1",
"definition_id": "identity-proof",
"descriptor_map": [
{ "id": "identity_input", "format": "dc+sd-jwt", "path": "$" }
]
}
}
Use SdJwt.Net.Oid4Vp for the OpenID4VP request/response parameters and SdJwt.Net.SiopV2 to issue and validate the subject-signed ID Token.
DCQL: lightweight query alternative¶
DCQL (Digital Credentials Query Language) is a simpler alternative to Presentation Exchange. Use DCQL for straightforward queries; use PEX for complex matching logic.
DCQL request example¶
{
"client_id": "https://age-check.example.com",
"response_type": "vp_token",
"nonce": "xyz789",
"dcql_query": {
"credentials": [
{
"id": "age_credential",
"format": "dc+sd-jwt",
"meta": {
"vct_values": ["https://credentials.example.com/DriverLicense"]
},
"claims": [{ "path": ["age_over_21"] }]
}
]
}
}
| Feature | DCQL | Presentation Exchange |
|---|---|---|
| Complexity | Simple, flat structure | Rich, nested requirements |
| Use case | Single credential, specific claims | Multiple credentials, complex logic |
| Submission requirements | Not supported | Supported (pick_n, all, etc.) |
| Filter expressions | Limited | Full JSONPath + filter objects |
Code example: verifier side¶
Building a request¶
using SdJwt.Net.Oid4Vp.Verifier;
using SdJwt.Net.Oid4Vp.Models;
// Create a presentation request using the fluent builder
var request = PresentationRequestBuilder
.Create(
clientId: "https://employer-check.example.com",
responseUri: "https://employer-check.example.com/callback"
)
.WithName("Employment Verification")
.WithPurpose("Verify current employment for background check")
.WithInputDescriptor(
id: "employment_credential",
name: "Employment Credential",
purpose: "Proof of current employment",
constraints => constraints
.AddField("$.vct", filter: FilterBuilder.Pattern(".*employment.*"))
.AddField("$.position")
.AddField("$.department", optional: true)
)
.Build();
// Serialize to JSON for QR code or deep link
string requestJson = request.ToJson();
// Store nonce server-side for validation
string expectedNonce = request.Nonce;
Validating a response¶
using SdJwt.Net.Oid4Vp.Verifier;
using SdJwt.Net.Oid4Vp.Models;
// Parse the incoming response
var response = AuthorizationResponse.FromJson(responseJson);
// Create validator with key resolver
var validator = new VpTokenValidator(
keyProvider: async jwt => await ResolveIssuerKey(jwt),
useSdJwtVcValidation: true
);
// Validate the response
var result = await validator.ValidateAsync(
response,
expectedNonce: storedNonce,
options: new VpTokenValidationOptions
{
ValidateKeyBinding = true,
ExpectedAudience = "https://employer-check.example.com",
IatFreshnessWindow = TimeSpan.FromMinutes(5)
}
);
if (result.IsValid)
{
// Access validated claims
foreach (var token in result.ValidatedTokens)
{
var position = token.DisclosedClaims["position"];
Console.WriteLine($"Verified position: {position}");
}
}
else
{
Console.WriteLine($"Validation failed: {result.ErrorMessage}");
}
Code example: wallet side¶
Processing a request and building response¶
using SdJwt.Net.Holder;
using SdJwt.Net.Oid4Vp.Models;
// Parse the verifier's request
var request = AuthorizationRequest.FromJson(requestJson);
// Find matching credentials in wallet storage
var matchingCredentials = await FindCredentialsMatching(request.PresentationDefinition);
// User approves which claims to disclose
var userApprovedClaims = await ShowConsentScreen(matchingCredentials, request);
// Create presentation for each matched credential
var presentations = new List<string>();
foreach (var (credential, claimsToDisclose) in userApprovedClaims)
{
var holder = new SdHolder(holderPrivateKey);
var presentation = holder.CreatePresentation(
credential.Issuance,
claimsToDisclose,
new KeyBindingOptions
{
Nonce = request.Nonce,
Audience = request.ClientId,
IssuedAt = DateTimeOffset.UtcNow
}
);
presentations.Add(presentation);
}
// Build the response
var response = new AuthorizationResponse
{
VpToken = presentations.Count == 1 ? presentations[0] : null,
VpTokenArray = presentations.Count > 1 ? presentations.ToArray() : null,
PresentationSubmission = BuildSubmission(request.PresentationDefinition, presentations),
State = request.State
};
// POST to verifier's response_uri
await HttpClient.PostAsync(request.ResponseUri, response.ToJson());
Validation checklist¶
A complete OID4VP validation involves three layers:
Layer 1: Protocol validation¶
| Check | What to Validate | Failure Meaning |
|---|---|---|
| Response structure | vp_token and presentation_submission present |
Malformed response |
| Definition ID match | presentation_submission.definition_id matches request |
Wrong submission |
| Descriptor coverage | All required input descriptors have mappings | Incomplete submission |
| State match | state in response matches request (if sent) |
Session mismatch |
Layer 2: Cryptographic validation¶
| Check | What to Validate | Failure Meaning |
|---|---|---|
| Issuer signature | SD-JWT signature valid against issuer key | Tampered or forged credential |
| Disclosure digests | All disclosed claims have valid digests in JWT | Disclosure tampering |
| KB-JWT signature | Key binding JWT signed by holder key | Holder not proven |
Layer 3: Binding and freshness¶
| Check | What to Validate | Failure Meaning |
|---|---|---|
| Nonce | KB-JWT nonce matches request nonce |
Replay attack |
| Audience | KB-JWT aud matches client_id |
Response intended for different verifier |
| Freshness | KB-JWT iat within acceptable window (e.g., 5 min) |
Stale presentation |
flowchart TD
A[Receive Response] --> B{Parse OK?}
B -->|No| REJECT[Reject]
B -->|Yes| C{Protocol checks pass?}
C -->|No| REJECT
C -->|Yes| D{Crypto checks pass?}
D -->|No| REJECT
D -->|Yes| E{Binding checks pass?}
E -->|No| REJECT
E -->|Yes| ACCEPT[Accept]
Implementation references¶
| Component | File | Description |
|---|---|---|
| Request model | AuthorizationRequest.cs | Request structure |
| Response model | AuthorizationResponse.cs | Response structure |
| Submission model | PresentationSubmission.cs | Descriptor mapping |
| Constants | Oid4VpConstants.cs | Protocol constants |
| Request builder | PresentationRequestBuilder.cs | Fluent request builder |
| VP validator | VpTokenValidator.cs | Full validation logic |
| DCQL query | DcqlQuery.cs | DCQL query model |
| Package overview | README.md | Quick start |
| Sample code | OpenId4VpExample.cs | Working examples |
Beginner pitfalls to avoid¶
1. Skipping nonce validation¶
Wrong:
// DANGEROUS - no nonce check
var result = await validator.ValidateAsync(response, expectedNonce: "", options);
Right:
// Store nonce when creating request
var nonce = request.Nonce;
await StoreNonceForSession(sessionId, nonce);
// Validate with stored nonce
var storedNonce = await GetNonceForSession(sessionId);
var result = await validator.ValidateAsync(response, expectedNonce: storedNonce, options);
Without nonce validation, an attacker can replay a captured response to gain unauthorized access.
2. Ignoring key binding audience¶
Wrong:
// Missing audience validation
var options = new VpTokenValidationOptions
{
ValidateKeyBinding = true
// ExpectedAudience not set!
};
Right:
var options = new VpTokenValidationOptions
{
ValidateKeyBinding = true,
ExpectedAudience = "https://my-service.example.com" // Must match client_id
};
Without audience validation, a response intended for a different verifier could be replayed to your service.
3. Mixing DCQL and presentation definition¶
Wrong:
{
"client_id": "...",
"nonce": "...",
"dcql_query": { ... },
"presentation_definition": { ... }
}
Right: Use exactly one query mechanism:
{
"client_id": "...",
"nonce": "...",
"dcql_query": { ... }
}
4. Not validating descriptor mappings¶
Validate that each required input descriptor has a corresponding entry in descriptor_map with the correct format and path, not just that the VP tokens exist.
// The VpTokenValidator handles this automatically
var result = await validator.ValidateAsync(response, nonce, options);
// Check that all required descriptors were satisfied
if (!result.AllDescriptorsSatisfied)
{
Console.WriteLine("Missing required credentials");
}
5. Insufficient Freshness Window¶
Wrong:
// Too tight - network latency can cause false rejections
IatFreshnessWindow = TimeSpan.FromSeconds(10)
// Too loose - allows stale presentations
IatFreshnessWindow = TimeSpan.FromHours(24)
Right:
// Balance between security and usability
IatFreshnessWindow = TimeSpan.FromMinutes(5)
Frequently Asked Questions¶
Q: What is the difference between response_uri and redirect_uri?¶
A:
response_uri: Where the Wallet POSTs the response (used withdirect_postmode)redirect_uri: Where the user agent redirects after authorization (used with redirect flows)
For cross-device flows (QR code), use response_uri with direct_post mode.
Q: When should I use DCQL vs Presentation Exchange?¶
A: Use DCQL for simple, single-credential queries. Use Presentation Exchange when you need:
- Multiple credentials with complex relationships
- Submission requirements (e.g., "any 2 of these 4 credentials")
- Rich filtering with JSONPath expressions
Q: How do I handle multiple issuers for the same credential type?¶
A: Define the input descriptor with a filter on the $.iss claim that accepts multiple values:
{
"path": ["$.iss"],
"filter": {
"type": "string",
"enum": ["https://issuer1.example.com", "https://issuer2.example.com"]
}
}
Q: Can the Wallet decline to present certain claims?¶
A: Yes. If a field is marked optional: true in the input descriptor, the Wallet may omit it. If required fields are missing, the Verifier should reject the presentation.
Q: How do I test OID4VP flows during development?¶
A: Use the sample code in OpenId4VpExample.cs which demonstrates complete verifier and wallet interactions with test credentials.
Related concepts¶
- Presentation Exchange - Query language for credential requirements
- SD-JWT - Base selective disclosure format
- Verifiable Credential - VC semantics transported by OID4VP
- OID4VCI - Credential issuance (complements OID4VP)