Verifiable credentials and decentralized identity (DID) are w3c standards, from 2019 and 2022, with v2 in May 2025 supporting:
- Json web tokens and other formats (originally just JSON-LD)
- OpenId aligned protocols (OpenID4VCI and OpenID4VP, see below)
- selective disclosure (only the specific data the verifier needs eg email, not all the data about the user)
See Wikipedia, verifiablecredentials.dev (Auth0), dock.io.
- In federated identity management (openId connect, SAML), the identity provider (Idp) must know all the users and all the systems they can connect to. This is the standard enterprise model.
- In the decentralized model, we'll take the standard did:web model:
- The issuer gives the credentials. They publish a well-known url https://example.com/.well-known/did.json
- The holder (the user) stores the credentials in a digital wallet; they can create a Verifiable Presentation (VP) for verifiers. Google Wallet, Apple Wallet and the main authenticator apps support the did:web protocol; other digital identity and bank apps use similar but older custom protocols.
- The verifier trusts the issuer (they can read the well-known url and check revocations etc) and will accept valid VPs. Equivalent to OAuth 2.0 Client/Relying Party
While did:web is the standard method, others include:
- did:web is the standard type, and uses the domain name (did:web:example.com). Based on DNS and TLS.
- did:key is just a public key- it can never change, so it's for offline use or testing
- did:ion uses the Bitcoin blockchain, and is slow
- There are other specialized did types.
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"
}
]
}
- id- for did:web, this must match the hosting domain, here issuer.example.com
- verificationMethod - includes the public keys for the DID (here one for authentication, one for key signing/assertionMethod)
- authentication - the DID key(s) for login, here did:web:issuer.example.com#login
- assertionMethod - the key(s) to sign verifiable credentials (verifiers check this). Here did:web:issuer.example.com#key-signing
- service - wallets and verifiers can get credentials, check revocation etc
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..."
}
}
- type is VerifiablePresentation
- holder is the DID (tells the verifier to check https://holder.example.com/.well-known/did.json)
- verifiableCredential is one or more credentials, each with an issuer and a proof (with a signature that will be verified)
- proof is the authentication signature of the holder
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
- From the VC, it reads the issuer (eg "issuer": "did:web:issuer.example.com")
- It then checks the DID document https://issuer.example.com/.well-known/did.json
- From the DID document it reads the assertionMethod which gives the DID key
- That gives the verificationMethod with the public key, to prove it is signed.
- OpenID4VP defines an HTTP response (http 200 or error codes)
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;
}
}