Skip to content

Tutorial: mdoc OpenID4VP integration

Build complete mdoc presentation flows with OpenID4VP for production verification.

Time: 25 minutes
Level: Advanced
Sample: samples/SdJwt.Net.Samples/03-Advanced/05-MdocIntegration.cs

What you will learn

  • How to integrate mdoc with OpenID4VP presentation requests
  • How to create session transcripts for different flows
  • How to verify mdoc presentations
  • How to combine mdoc with SD-JWT VC in multi-credential scenarios

Simple explanation

This tutorial integrates mdoc credentials with OID4VP so that a mobile driving license can be presented through the same protocol flow as SD-JWT VC credentials.

What is SessionTranscript? SessionTranscript is a CBOR structure that binds an mdoc response to a specific request. It prevents an attacker from replaying a captured presentation in a different context. For OID4VP, it includes the nonce, client_id, and response_uri from the authorization request.

Packages used

Package Purpose
SdJwt.Net.Mdoc mdoc credential handling
SdJwt.Net.Oid4Vp OID4VP presentation protocol

Where this fits

flowchart LR
    A["mdoc credential\nin wallet"] --> B["OID4VP request\nfor mdoc"]
    B --> C["Build mdoc\npresentation"]
    C --> D["Verify mdoc\nvia OID4VP"]
    style A fill:#2a6478,color:#fff
    style B fill:#2a6478,color:#fff
    style C fill:#2a6478,color:#fff
    style D fill:#2a6478,color:#fff

Prerequisites

OpenID4VP mdoc flow overview

sequenceDiagram
    participant Wallet as Wallet (Holder)
    participant Verifier as Verifier (RP)
    participant Issuer as Issuer (DMV)

    Note over Wallet,Issuer: Credential Issuance (prior)
    Issuer->>Wallet: mdoc credential (CBOR)

    Note over Wallet,Verifier: Presentation Flow
    Verifier->>Wallet: OpenID4VP Request (mso_mdoc format)
    Wallet->>Wallet: Select claims to disclose
    Wallet->>Wallet: Create DeviceResponse with SessionTranscript
    Wallet->>Verifier: OpenID4VP Response (DeviceResponse CBOR)
    Verifier->>Verifier: Verify signature, claims, session binding

Session transcripts

OpenID4VP redirect flow

using SdJwt.Net.Mdoc.Handover;

// Standard redirect-based flow (same-device or cross-device)
var transcript = SessionTranscript.ForOpenId4Vp(
    clientId: "https://verifier.example.com",
    nonce: "xyz789-nonce",
    mdocGeneratedNonce: null, // Optional device nonce
    responseUri: "https://verifier.example.com/callback");

// Serialize for inclusion in DeviceResponse
byte[] transcriptCbor = transcript.ToCbor();

OpenID4VP DC API flow (browser)

// W3C Digital Credentials API flow (browser-based)
var dcApiTranscript = SessionTranscript.ForOpenId4VpDcApi(
    origin: "https://verifier.example.com",
    nonce: "browser-session-nonce",
    clientId: null); // Defaults to origin

Custom handover construction

using SdJwt.Net.Mdoc.Handover;

// Manual handover creation for flexibility
var handover = OpenId4VpHandover.Create(
    clientId: "https://verifier.example.com",
    responseUri: "https://verifier.example.com/response",
    nonce: "verifier-nonce",
    mdocGeneratedNonce: "wallet-generated-nonce");

var transcript = new SessionTranscript
{
    DeviceEngagement = null,  // Not used in OID4VP
    EReaderKeyPub = null,     // Not used in OID4VP
    Handover = handover
};

Creating DeviceResponse

Build presentation response

using SdJwt.Net.Mdoc.Models;

// Assume we have an issued mdoc
var document = /* previously issued Document */;

// Create device response for presentation
var deviceResponse = new DeviceResponse
{
    Version = "1.0",
    Documents = new List<Document> { document },
    Status = 0 // Success
};

// Serialize for transmission
byte[] responseBytes = deviceResponse.ToCbor();

Selective disclosure in mdoc

Unlike SD-JWT where disclosures are selective at issuance, mdoc selective disclosure happens at presentation time. The holder creates a DeviceResponse containing only the namespaces and elements they wish to disclose.

// Create response with selected elements only
// (Implementation depends on your presentation logic)
var selectiveDocument = new Document
{
    DocType = originalDoc.DocType,
    IssuerSigned = new IssuerSigned
    {
        NameSpaces = FilterNamespaces(
            originalDoc.IssuerSigned.NameSpaces,
            requestedElements),
        IssuerAuth = originalDoc.IssuerSigned.IssuerAuth
    }
};

Verifying mdoc presentations

Basic verification

using SdJwt.Net.Mdoc.Verifier;

var verifier = new MdocVerifier();

var options = new MdocVerificationOptions
{
    VerifyValidity = true,
    VerifyCertificateChain = true,
    VerifyDeviceSignature = true,
    AllowedDocTypes = new List<string> { "org.iso.18013.5.1.mDL" },
    ClockSkewTolerance = TimeSpan.FromMinutes(5)
};

var result = verifier.VerifyDocument(document);

if (result.IsValid)
{
    Console.WriteLine("Verification successful");

    // Access verified claims (namespace -> element -> value)
    foreach (var ns in result.VerifiedClaims)
    {
        foreach (var element in ns.Value)
        {
            Console.WriteLine($"{ns.Key}/{element.Key}: {element.Value}");
        }
    }
}
else
{
    Console.WriteLine($"Verification failed:");
    foreach (var error in result.Errors)
    {
        Console.WriteLine($"  - {error}");
    }
}

Full verification pipeline

public class MdocVerificationService
{
    private readonly MdocVerifier _verifier = new();

    public async Task<VerificationOutcome> VerifyPresentationAsync(
        byte[] presentationBytes,
        string expectedNonce,
        string verifierClientId)
    {
        // Parse device response
        var response = DeviceResponse.FromCbor(presentationBytes);

        // Verify response status
        if (response.Status != 0)
        {
            return VerificationOutcome.Failed($"Device error: {response.Status}");
        }

        var outcomes = new List<DocumentVerification>();

        foreach (var doc in response.Documents)
        {
            var options = new MdocVerificationOptions
            {
                VerifyValidity = true,
                VerifyCertificateChain = true,
                AllowedDocTypes = new List<string> { doc.DocType }
            };

            var result = _verifier.VerifyDocument(doc);

            outcomes.Add(new DocumentVerification
            {
                DocType = doc.DocType,
                IsValid = result.IsValid,
                Claims = result.VerifiedClaims,
                Errors = result.Errors
            });
        }

        return new VerificationOutcome
        {
            Success = outcomes.All(o => o.IsValid),
            Documents = outcomes
        };
    }
}

Multi-credential flows

Combined SD-JWT VC and mdoc

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

// Create presentation definition requesting both formats
var presentationDefinition = new PresentationDefinition
{
    Id = "multi-credential-request",
    InputDescriptors = new[]
    {
        // SD-JWT VC credential
        new InputDescriptor
        {
            Id = "employment-proof",
            Format = new Dictionary<string, object>
            {
                ["dc+sd-jwt"] = new { alg = new[] { "ES256" } }
            },
            Constraints = new Constraints
            {
                Fields = new[]
                {
                    new Field { Path = new[] { "$.vct" }, Filter = new Filter { Const = "EmploymentCredential" } }
                }
            }
        },
        // mdoc credential
        new InputDescriptor
        {
            Id = "age-verification",
            Format = new Dictionary<string, object>
            {
                ["mso_mdoc"] = new { alg = new[] { "ES256" } }
            },
            Constraints = new Constraints
            {
                Fields = new[]
                {
                    new Field
                    {
                        Path = new[] { "$['org.iso.18013.5.1']['age_over_21']" },
                        Filter = new Filter { Const = true }
                    }
                }
            }
        }
    }
};

Processing multi-format response

public class MultiFormatVerifier
{
    private readonly VpTokenValidator _sdJwtValidator;
    private readonly MdocVerifier _mdocVerifier;

    public async Task<CombinedResult> VerifyMultiFormatAsync(
        AuthorizationResponse response)
    {
        var results = new CombinedResult();

        foreach (var vpToken in response.VpTokens)
        {
            if (vpToken.Format == "dc+sd-jwt")
            {
                // Verify SD-JWT VC
                var sdJwtResult = await _sdJwtValidator.ValidateAsync(
                    vpToken.Token,
                    new VpValidationParameters { /* ... */ });
                results.SdJwtCredentials.Add(sdJwtResult);
            }
            else if (vpToken.Format == "mso_mdoc")
            {
                // Verify mdoc
                var mdocBytes = Convert.FromBase64String(vpToken.Token);
                var document = Document.FromCbor(mdocBytes);
                var mdocResult = _mdocVerifier.Verify(document, new MdocVerificationOptions());
                results.MdocCredentials.Add(mdocResult);
            }
        }

        return results;
    }
}

HAIP Final compliance

HAIP Final validates mdoc support by selected flow and credential profile. For mdoc presentations, declare the OID4VP flow, the mso_mdoc profile, COSE ES256 support, SHA-256 support, device signature validation, and x5chain trust validation where x5chain is used.

using SdJwt.Net.HAIP;
using SdJwt.Net.HAIP.Validators;
using SdJwt.Net.Mdoc.Cose;

var options = new HaipProfileOptions();
options.Flows.Add(HaipFlow.Oid4VpDigitalCredentialsApiPresentation);
options.CredentialProfiles.Add(HaipCredentialProfile.MsoMdoc);
options.SupportedCredentialFormats.Add(HaipConstants.MsoMdocFormat);
options.SupportedJoseAlgorithms.Add(HaipConstants.RequiredJoseAlgorithm);
options.SupportedCoseAlgorithms.Add((int)CoseAlgorithm.ES256);
options.SupportedHashAlgorithms.Add(HaipConstants.RequiredHashAlgorithm);
options.SupportsDigitalCredentialsApi = true;
options.SupportsDcql = true;
options.ValidatesMdocDeviceSignature = true;
options.ValidatesMdocX5Chain = true;

var haipResult = new HaipProfileValidator().Validate(options);
if (!haipResult.IsCompliant)
{
    throw new SecurityException("mdoc profile does not meet HAIP Final requirements");
}

// Issue with HAIP Final minimum COSE support
using var issuerKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);

var mdoc = await new MdocIssuerBuilder()
    .WithDocType("org.iso.18013.5.1.mDL")
    .WithIssuerKey(CoseKey.FromECDsa(issuerKey))
    .WithDeviceKey(deviceKey)
    .WithAlgorithm(CoseAlgorithm.ES256)
    .AddMdlElement(MdlDataElement.FamilyName, "Smith")
    // ... other elements
    .BuildAsync(cryptoProvider);

Complete integration example

public class MdocOpenId4VpService
{
    private readonly HttpClient _httpClient;
    private readonly MdocVerifier _verifier;

    public async Task<OpenId4VpOutcome> ProcessMdocPresentationAsync(
        string authorizationRequestUri,
        Document holderMdoc,
        ECDsa devicePrivateKey)
    {
        // 1. Fetch and parse authorization request
        var authRequest = await FetchAuthorizationRequestAsync(authorizationRequestUri);

        // 2. Create session transcript
        var transcript = SessionTranscript.ForOpenId4Vp(
            clientId: authRequest.ClientId,
            nonce: authRequest.Nonce,
            mdocGeneratedNonce: GenerateNonce(),
            responseUri: authRequest.ResponseUri);

        // 3. Create device response
        var deviceResponse = new DeviceResponse
        {
            Version = "1.0",
            Documents = new List<Document> { holderMdoc },
            Status = 0
        };

        // 4. Submit response
        var responseBytes = deviceResponse.ToCbor();
        var result = await SubmitPresentationAsync(
            authRequest.ResponseUri,
            Convert.ToBase64String(responseBytes));

        return result;
    }

    private string GenerateNonce()
    {
        var bytes = new byte[16];
        RandomNumberGenerator.Fill(bytes);
        return Convert.ToBase64String(bytes);
    }
}

Run the sample

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

Expected output

mdoc loaded: org.iso.18013.5.1.mDL
OID4VP request: requesting family_name, given_name, portrait
SessionTranscript created
mdoc presentation built with DeviceAuth
Verifier: mdoc signature valid, SessionTranscript matches

Demo vs production

mdoc presentations require a SessionTranscript that binds the response to the specific request context. In proximity (BLE/NFC) flows, the SessionTranscript includes device engagement data.

Common mistakes

  • Omitting SessionTranscript (required for mdoc presentations; prevents replay)
  • Expecting mdoc elements to use the same paths as SD-JWT claims (mdoc uses namespace + element identifier, not flat JSON paths)

Next steps

Key concepts

Concept Description
SessionTranscript CBOR binding between request and response
DeviceResponse Holder's presentation response container
OpenID4VP Standard protocol for credential presentation
DC API W3C Digital Credentials API for browsers
Multi-format Combining SD-JWT VC and mdoc credentials