Skip to content

Tutorial: OpenID4VP

Implement credential presentation using the OpenID for Verifiable Presentations protocol.

Time: 20 minutes
Level: Intermediate
Sample: samples/SdJwt.Net.Samples/02-Intermediate/04-OpenId4Vp.cs

What you will learn

  • OpenID4VP authorization request flow
  • Presentation definition creation
  • Response handling and validation

Simple explanation

OID4VP is the protocol for proving something from a wallet to a verifier. The verifier sends a request describing what it needs, the wallet finds matching credentials, and sends back a presentation with only the required claims.

Packages used

Package Purpose
SdJwt.Net.Oid4Vp OID4VP protocol models and validation
SdJwt.Net.PresentationExchange Credential matching (DIF PE)

Where this fits

flowchart LR
    A["Issuer"] -->|"OID4VCI"| B["Wallet"]
    B -->|"OID4VP"| C["Verifier"]
    style B fill:#2a6478,color:#fff
    style C fill:#2a6478,color:#fff

OID4VCI vs OID4VP

Aspect OID4VCI OID4VP
Direction Issuer to Wallet Wallet to Verifier
Purpose Receive a credential Prove claims from a credential
Request starts at Issuer (offer) Verifier (authorization request)
Key binding Wallet proves key at issuance Wallet proves key at presentation
Query language N/A DCQL or Presentation Exchange

Protocol overview

sequenceDiagram
    participant Wallet
    participant Verifier

    Verifier->>Wallet: 1. Authorization Request (with presentation_definition)
    Wallet->>Wallet: 2. User consent
    Wallet->>Verifier: 3. Authorization Response (with vp_token)
    Verifier->>Verifier: 4. Verification

Step 1: Verifier creates request

using SdJwt.Net.Oid4Vp.Models;
using SdJwt.Net.PresentationExchange.Models;

var authRequest = new AuthorizationRequest
{
    ResponseType = "vp_token",
    ClientId = "https://verifier.example.com",
    RedirectUri = "https://verifier.example.com/callback",
    Nonce = Guid.NewGuid().ToString(),
    State = "session-state-123",
    PresentationDefinition = new PresentationDefinition
    {
        Id = "employment-verification",
        InputDescriptors = new[]
        {
            new InputDescriptor
            {
                Id = "employee-credential",
                Name = "Employment Proof",
                Purpose = "Verify current employment",
                Constraints = new Constraints
                {
                    Fields = new[]
                    {
                        new Field
                        {
                            Path = new[] { "$.vct" },
                            Filter = new FieldFilter
                            {
                                Type = "string",
                                Const = "https://hr.example/EmployeeCredential"
                            }
                        },
                        new Field
                        {
                            Path = new[] { "$.employer_name" }
                        }
                    }
                }
            }
        }
    }
};

Step 2: Send request to wallet

// Option A: Same-device (deep link)
var requestUri = $"openid4vp://?{BuildQueryString(authRequest)}";

// Option B: Cross-device (QR code)
var qrContent = $"openid4vp://?request_uri={Uri.EscapeDataString(hostedRequestUri)}";

Step 3: Wallet processes request

// Parse authorization request
var request = ParseAuthorizationRequest(requestUri);

// Find matching credentials in wallet
var matchingCredentials = wallet.FindCredentials(request.PresentationDefinition);

// User selects which credentials to present
var selectedCredential = matchingCredentials.First();

// Create selective presentation
var holder = new SdJwtHolder(selectedCredential);
var presentation = holder.CreatePresentation(
    d => d.ClaimName == "employer_name",  // Only required fields
    kbJwtPayload: new JwtPayload
    {
        ["aud"] = request.ClientId,
        ["nonce"] = request.Nonce
    },
    kbJwtSigningKey: holderKey,
    kbJwtSigningAlgorithm: SecurityAlgorithms.EcdsaSha256
);

Step 4: Wallet sends response

var authResponse = new AuthorizationResponse
{
    VpToken = presentation,
    PresentationSubmission = new PresentationSubmission
    {
        Id = Guid.NewGuid().ToString(),
        DefinitionId = request.PresentationDefinition.Id,
        DescriptorMap = new[]
        {
            new DescriptorMapEntry
            {
                Id = "employee-credential",
                Format = "dc+sd-jwt",
                Path = "$"
            }
        }
    },
    State = request.State
};

// POST to redirect_uri
await httpClient.PostAsync(request.RedirectUri, authResponse);

Step 5: Verifier validates response

// Validate state matches
if (response.State != expectedState)
    throw new SecurityException("State mismatch");

// Verify the SD-JWT presentation
var verifier = new SdVerifier(ResolveIssuerKey);
var result = await verifier.VerifyAsync(
    response.VpToken,
    sdJwtParams,
    kbJwtParams,
    expectedNonce
);

// Check presentation submission matches definition
ValidatePresentationSubmission(
    response.PresentationSubmission,
    originalRequest.PresentationDefinition
);

// Extract verified claims
var employerName = result.ClaimsPrincipal.FindFirst("employer_name")?.Value;
Console.WriteLine($"Verified employment at: {employerName}");

Response modes

Direct post

Response sent directly to verifier backend:

var request = new AuthorizationRequest
{
    ResponseMode = "direct_post",
    ResponseUri = "https://verifier.example.com/response"
};

Fragment

Response in URL fragment (same-device):

var request = new AuthorizationRequest
{
    ResponseMode = "fragment",
    RedirectUri = "https://verifier.example.com/callback"
};

Multiple credentials

Request multiple credentials at once:

var definition = new PresentationDefinition
{
    Id = "full-verification",
    InputDescriptors = new[]
    {
        new InputDescriptor { Id = "id-credential", ... },
        new InputDescriptor { Id = "address-credential", ... },
        new InputDescriptor { Id = "employment-credential", ... }
    }
};

Presentation Exchange validation

When the request uses presentation_definition, keep the shared PEX definition and pass it to the OID4VP validator. Validation then checks the wallet's presentation_submission against the verified disclosed claims.

var options = VpTokenValidationOptions.CreateForOid4Vp("https://verifier.example.com");
options.ExpectedPresentationExchangeDefinition = expectedPresentationDefinition;

var result = await validator.ValidateAsync(response, expectedNonce, options);

Run the sample

cd samples/SdJwt.Net.Samples
dotnet run -- 2.4

Next steps

Key takeaways

  1. OpenID4VP standardizes credential presentation
  2. Presentation definitions specify required credentials
  3. Wallet creates selective disclosures based on requirements
  4. OID4VP validation can enforce Presentation Exchange constraints after token verification
  5. Nonces prevent replay attacks

Expected output

Authorization request created with nonce
Wallet matched 1 credential
Presentation submitted with KB-JWT
Verifier: signature valid, nonce matches

Demo vs production

In production, the wallet receives the authorization request via a deep link or QR code. The response goes to the verifier's response_uri endpoint. The redirect_uri is not used in OID4VP; response_uri is the correct parameter.

Common mistakes

  • Using redirect_uri instead of response_uri in the authorization request (OID4VP uses response_uri)
  • Forgetting the nonce in the authorization request (required for replay protection)