static void

Verifiable credentials

Published Wednesday 18 February 2026

Verifiable credentials and decentralized identity (DID) are w3c standards, from 2019 and 2022, with v2 in May 2025 supporting:

See Wikipedia, verifiablecredentials.dev (Auth0), dock.io.

While did:web is the standard method, others include:

It is possible to create your own organisational did:web, or you can use a provider like Microsoft Entra Verified ID, SpruceId, Digital Bazaar, Trinsic.

Issuer

The issuer serves the following json document from https://issuer.example.com/.well-known/did.json


{
  "@context": [
    "https://www.w3.org/ns/did/v1"
  ],
  "id": "did:web:issuer.example.com",
  "verificationMethod": [
    {
      "id": "did:web:issuer.example.com#login",
      "type": "Ed25519VerificationKey2020",
      "controller": "did:web:issuer.example.com",
      "publicKeyMultibase": "z6MkhQZ7E92b5UAZ6yspZgZ4Z8mwA1lyQbxhxCgjEuQX9aQp"
    },
    {
      "id": "did:web:issuer.example.com#key-signing",
      "type": "JsonWebKey2020",
      "controller": "did:web:issuer.example.com",
      "publicKeyJwk": {
        "kty": "EC",
        "crv": "secp256k1",
        "x": "2c5JvxeLg2Uhyb5s3pKy7YkYUTjP6zWEW9JY9Sz8Vt4",
        "y": "QCV3QG6zXz0shR5kZOUQ2XWZ9Zlkk6-e2vC1AmkH3u8"
      }
  ],
  "authentication": [
    "did:web:issuer.example.com#login"
  ],
  "assertionMethod": [
    "did:web:issuer.example.com#key-signing"
  ],
  "service": [
    {
      "id": "did:web:issuer.example.com#vc-issuer",
      "type": "CredentialIssuerService",
      "serviceEndpoint": "https://issuer.example.com/vc"
    },
    {
      "id": "did:web:issuer.example.com#status",
      "type": "CredentialStatusService",
      "serviceEndpoint": "https://issuer.example.com/status"
    }
  ]
}

Issuer to Holder- enrolment

The Holder's wallet has to register/enrol with the issuer. There is a protocol, OpenID4VCI (Verifiable Credential Issuance), for this. This is normally a one-time registration.

Not to be confused with OpenID4VP (a protocol to create the verifiable credential) which is between the verifier and holder.

Entra Verified Id is not the same as Entra Id (Azure AD, the IdP), but you can use ID token hints (claim mapping) to pull name. email, id etc from Entra Id into the verified creds.

NB: verified credentials are immutable and permanent. How can they be changed? By publishing revocation lists (StatusList2021 or StatusList2023).

Verifier to Holder - request credentials

First the verifier initiates a request for credentials. Normally this should include a cryptographic challenge (nonce)

There is no standard way, but the common use is to show a QR code which you scan in your wallet. The wallet does the verification process, and when the verifier gets the completed credential it can continue.

If you know there is a wallet insatlled, you could also use a custom URL scheme link: window.location.href = "walletapp://vp-request?jwt=..." or use the CHAPI pollyfill.

The modern way is to use the navigator.credentials api (Chrome/Edge, partially in Firefox)


const vp = await navigator.credentials.get({
  mediation: "optional",
  digital: {
    request: presentationRequestJWT
  }
});

Here we generate a


var builder = new RequestBuilder();

var request = builder.CreatePresentationRequest(
    verifierClientId: "https://verifier.example.com",
    redirectUri: "https://verifier.example.com/callback",
    walletOpenIdEndpoint: "openid4vp://authorize"
);

Console.WriteLine("Nonce: " + request.Nonce);
Console.WriteLine("Audience: " + request.Audience);
Console.WriteLine("Auth Request URL: " + request.AuthorizationRequestUrl);
//store the nonce and audience server side (to verify when it comes back)
//show a QR code or redirect to give them this json request

And the builder that does the work:


using System.Security.Cryptography;
using System.Web;

public class PresentationRequest
{
    public string Nonce { get; set; }
    public string State { get; set; }
    public string Audience { get; set; }
    public string AuthorizationRequestUrl { get; set; }
}

public class RequestBuilder
{
    public PresentationRequest CreatePresentationRequest(
        string verifierClientId,
        string redirectUri,
        string walletOpenIdEndpoint)
    {
        // 1. Generate nonce + state
        var nonce = GenerateRandomString(32);
        var state = GenerateRandomString(32);

        // 2. Build authorization request (OpenID4VP)
        var query = HttpUtility.ParseQueryString(string.Empty);
        query["client_id"] = verifierClientId;
        query["response_type"] = "vp_token id_token";
        query["redirect_uri"] = redirectUri;
        query["scope"] = "openid";
        query["nonce"] = nonce;
        query["state"] = state;
        query["presentation_definition_uri"] = 
            "https://your-verifier.example.com/presentation-definition.json";

        var url = $"{walletOpenIdEndpoint}?{query}";

        return new PresentationRequest
        {
            Nonce = nonce,
            State = state,
            Audience = verifierClientId,
            AuthorizationRequestUrl = url
        };
    }

    private string GenerateRandomString(int length)
    {
        var bytes = RandomNumberGenerator.GetBytes(length);
        return Convert.ToBase64String(bytes)
            .Replace("+", "")
            .Replace("/", "")
            .Replace("=", "");
    }
}

Holder to Verifier - send Verifiable Presentation

The holder (user with a digital wallet) creates a Verifiable Presentation (VP) and signs it with the nonce and holder signature.


{
  "@context": [
    "https://www.w3.org/2018/credentials/v1"
  ],
  "type": ["VerifiablePresentation"],
  "holder": "did:web:holder.example.com",
  "verifiableCredential": [
    {
      "@context": [
        "https://www.w3.org/2018/credentials/v1"
      ],
      "id": "urn:uuid:1e06cb04-3f27-48c3-95ff-e03a4ea5d6b1",
      "type": ["VerifiableCredential", "ExampleCredential"],
      "issuer": "did:web:issuer.example.com",
      "issuanceDate": "2026-02-10T12:00:00Z",
      "credentialSubject": {
        "id": "did:web:holder.example.com",
        "name": "Alice Doe"
      },
      "proof": {
        "type": "Ed25519Signature2020",
        "created": "2026-02-10T12:00:00Z",
        "verificationMethod": "did:web:issuer.example.com#key-signing",
        "proofPurpose": "assertionMethod",
        "jws": "eyJhbGciOiJFZERTQSJ9..."
      }
    }
  ],
  "proof": {
    "type": "Ed25519Signature2020",
    "created": "2026-02-17T08:30:00Z",
    "verificationMethod": "did:web:holder.example.com#key-1",
    "proofPurpose": "authentication",
    "challenge": "1234567890",
    "domain": "verifier.example.com",
    "jws": "eyJhbGciOiJFZERTQSJ9..."
  }
}

In did:web this is typically in a standard JWT with header (the kid is the did:web key)


{
  "alg": "ES256",
  "kid": "did:web:holder.example.com#key-1",
  "typ": "JWT"
}

and payload


{
  "iss": "did:web:holder.example.com",
  "sub": "did:web:holder.example.com",
  "iat": 1740079200,
  "vp": {
    "type": ["VerifiablePresentation"],
    "verifiableCredential": [
      "eyJhbGci..."
    ]
  }
}

The signature section ensures it can be verified and is tamper-proof. Depending on the details (unique nonces per transaction, say) you can prevent replay attacks.

Verifier checks the VP

Entra documents for issuer request verification

A simplified C# verifier.


//provided the nonce and audience you saved earlier (see request credentials)
var verifier = new Verifier(expectedNonce, expectedAudience);
return await verifier.IsValidAsync(idToken, vpToken);

And the simplified verifier. There are some commercial libraries, but (as of Feb 2026) not even an Entra Verified Credential library (see their samples).


//import these nugets
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using System.Net.Http.Json;
using System.Text.Json;

public class Verifier
{
    private readonly JsonWebTokenHandler _handler = new();
    private readonly HttpClient _http = new();

    private readonly string _expectedNonce;
    private readonly string _expectedAudience; // your verifier client_id

    public Verifier(string expectedNonce, string expectedAudience)
    {
        _expectedNonce = expectedNonce;
        _expectedAudience = expectedAudience;
    }

    public async Task<bool> VerifyOpenId4VpAsync(string idToken, string vpToken)
    {
        // 1. Validate ID Token (holder auth + nonce binding)
        var idTokenJwt = _handler.ReadJsonWebToken(idToken);
        if (!await ValidateJwtAsync(idTokenJwt))
            return false;

        if (!ValidateNonceAndAudience(idTokenJwt))
            return false;

        // 2. Validate VP Token (presentation)
        var vpJwt = _handler.ReadJsonWebToken(vpToken);
        if (!await ValidateJwtAsync(vpJwt))
            return false;

        // 3. Extract VC(s) from VP (assume JWT-based SD-JWT-VC)
        var vpPayload = JsonDocument.Parse(vpJwt.EncodedPayload).RootElement;
        var vcArray = vpPayload
            .GetProperty("vp")
            .GetProperty("verifiableCredential")
            .EnumerateArray();

        foreach (var vcElement in vcArray)
        {
            var sdJwtVc = vcElement.GetString(); // SD-JWT-VC (compact string)
            if (!await VerifySdJwtVcAsync(sdJwtVc))
                return false;
        }

        return true;
    }

    private bool ValidateNonceAndAudience(JsonWebToken idToken)
    {
        var nonce = idToken.GetClaim("nonce");
        var aud = idToken.Audiences?.FirstOrDefault();

        return nonce == _expectedNonce && aud == _expectedAudience;
    }

    private async Task<bool> ValidateJwtAsync(JsonWebToken jwt)
    {
        var issuerDid = jwt.Issuer;
        var jwk = await ResolveDidWebKeyAsync(issuerDid);

        var parameters = new TokenValidationParameters
        {
            ValidateIssuer = false,
            ValidateAudience = false,
            ValidateLifetime = true,
            IssuerSigningKey = new JsonWebKey(jwk)
        };

        var result = _handler.ValidateToken(jwt.EncodedToken, parameters);
        return result.IsValid;
    }

    private async Task<string> ResolveDidWebKeyAsync(string did)
    {
        var domain = did.Replace("did:web:", "").Replace(":", "/");
        var url = $"https://{domain}/.well-known/did.json";

        var didDoc = await _http.GetFromJsonAsync<DidDocument>(url);
		//VerificationMethod  contains an array of VerificationMethod each with a PublicKeyJwk object
        return didDoc.VerificationMethod[0].PublicKeyJwk.ToString();
    }

    // Selective disclosure JWT-VC + revocation check 
    private async Task<bool> VerifySdJwtVcAsync(string sdJwtVc)
    {
        // Split SD-JWT-VC: "<jwt>~disclosure1~disclosure2..."
        var parts = sdJwtVc.Split('~');
        var jwtPart = parts[0];
        var disclosures = parts.Skip(1).ToArray();

        var jwt = _handler.ReadJsonWebToken(jwtPart);

        // Verify issuer signature
        if (!await ValidateJwtAsync(jwt)) return false;

        // ToDo Reconstruct disclosed claims and verify hashes 

        // ToDo Check revocation (if credentialStatus present) using StatusList2021Entry

        return true;
    }
}

Previously: Net8 (Tuesday 14 November 2023)