OID4VCI¶
Level: Beginner protocol + implementation
Simple explanation¶
OID4VCI is the standard way for an issuer to place a verifiable credential into a wallet.
What you will learn¶
- The 5-step OID4VCI protocol flow
- How authorization, proof of possession, and credential response work
- What
SdJwt.Net.Oid4Vcihandles vs what your application must implement - How to configure issuer metadata and credential endpoints
If a verifiable credential is like a digital passport, OID4VCI is the secure counter process where the passport office checks that you are allowed to receive it, binds it to your wallet key, and gives it to your wallet in a standard format.
In one sentence: Issuer creates an offer, wallet reads issuer metadata, wallet gets authorization, wallet proves key possession, issuer returns credential.
Where it fits¶
OID4VCI is used only for issuance. It is not used when a holder presents a credential to a verifier. That is OID4VP.
| Protocol | Direction | Plain English |
|---|---|---|
| OID4VCI | Issuer to Wallet | "Put this credential into my wallet." |
| OID4VP | Wallet to Verifier | "Prove something from my wallet." |
What SD-JWT .NET provides¶
SdJwt.Net.Vccreates the SD-JWT VC credential.SdJwt.Net.Oid4Vcimodels the issuance protocol around it.- Your application still provides user authentication, authorization server integration, storage, and business approval.
| Audience | Developers implementing credential issuance flows (wallet-side or issuer-side), and architects designing issuance infrastructure. |
| Purpose | Explain how wallets discover, request, and receive credentials from issuers using the OpenID for Verifiable Credential Issuance protocol, with working SdJwt.Net.Oid4Vci code examples. |
| Scope | Credential offers, pre-authorized and authorization code grant flows, proof of possession, deferred issuance, and issuer-side validation. Out of scope: presentation protocol (see OID4VP), base SD-JWT format (see SD-JWT). |
| Success criteria | Reader can build a wallet-side credential request with proof JWT, implement an issuer endpoint that validates proofs and issues SD-JWT VCs, and handle deferred issuance polling. |
Prerequisites¶
Before reading this document, you should understand:
| Prerequisite | Why Needed | Resource |
|---|---|---|
| SD-JWT basics | OID4VCI issues SD-JWT credentials | SD-JWT |
| Verifiable Credentials | Issued credentials are SD-JWT VCs | VC |
Glossary¶
| Term | Definition |
|---|---|
| Credential Issuer | Entity that creates and signs credentials (university, DMV, employer) |
| Wallet | Application that requests and stores credentials for the Holder |
| Authorization Server | Issues access tokens that grant permission to request credentials |
| Credential Offer | Message from Issuer containing available credentials and grant parameters |
| c_nonce | Challenge nonce issued by Issuer for proof of possession |
| Proof JWT | JWT signed by Holder's key proving they control the binding key |
| Pre-authorized Code | Grant type where Issuer pre-approves credential issuance |
| Deferred Issuance | Pattern where credential is issued later via polling |
Why OID4VCI exists¶
A university wants to issue digital diplomas. A bank wants to issue identity credentials. Without a standard protocol, each issuer invents its own API, its own proof mechanism, and its own format. Wallets would need custom integrations per issuer.
OID4VCI standardizes the answer: how does a wallet discover what credentials an issuer offers, prove it is authorized to receive one, and prove it controls the key that will be bound to the credential? It does this through issuer metadata discovery, OAuth 2.0-based authorization, proof-of-possession via proof JWTs, and support for both immediate and deferred issuance.
A real scenario¶
A driver completes an in-person identity check at the DMV. The DMV system generates a credential offer (a QR code). The driver scans it with their wallet app. The wallet discovers the DMV's issuer metadata, exchanges the pre-authorized code for an access token, creates a proof JWT showing it controls a private key, and sends a credential request. The DMV's issuer endpoint validates the proof, creates an SD-JWT VC containing the driver's license data (name, address, date of birth, license class -- all selectively disclosable), binds it to the wallet's key, and returns it. The driver now holds a digital license they can present to any OID4VP-compatible verifier, revealing only the claims each verifier needs.
OID4VCI object map¶
CredentialOffer What the issuer advertises (QR code / deep link)
|
v
IssuerMetadata Endpoints, supported formats, credential types
|
v
TokenResponse access_token + c_nonce (from Authorization Server)
|
v
CredentialRequest format + vct + proof JWT (wallet -> issuer)
|
v
CredentialResponse SD-JWT VC credential or acceptance_token (deferred)
Roles in the issuance flow¶
flowchart LR
I[Issuer] -->|1. Publishes metadata| W[Wallet]
I -->|2. Sends credential offer| W
W -->|3. Requests access token| AS[Authorization Server]
AS -->|4. Returns token + c_nonce| W
W -->|5. Requests credential with proof| I
I -->|6. Issues credential| W
| Role | Responsibility |
|---|---|
| Issuer | Defines credential types, validates proofs, signs credentials |
| Wallet | Discovers issuers, requests credentials, stores issued credentials |
| Authorization Server | Authenticates users, issues access tokens, provides c_nonce |
Core artifacts¶
| Artifact | Purpose | Example |
|---|---|---|
CredentialOffer |
Entry point listing available credentials | QR code or deep link |
| Access Token | Grants permission to request credentials | Bearer token |
c_nonce |
Challenge for proof of possession | Random string from token response |
CredentialRequest |
Wallet request with format and proof | POST to /credential endpoint |
| Proof JWT | Proves Holder controls binding key | JWT with c_nonce and audience |
CredentialResponse |
Issued credential or deferred token | SD-JWT VC or acceptance_token |
Grant patterns¶
Pre-authorized code flow¶
The issuer pre-approves credential issuance. This is the most common pattern for scenarios where the user has already been authenticated elsewhere.
sequenceDiagram
autonumber
participant User
participant Issuer
participant Wallet
User->>Issuer: Complete enrollment
Issuer->>Issuer: Generate pre-authorized code
Issuer-->>User: Credential offer (QR/deep link)
User->>Wallet: Scan offer
Wallet->>Issuer: Exchange code for token
Issuer-->>Wallet: access_token + c_nonce
Wallet->>Wallet: Create proof JWT
Wallet->>Issuer: Credential request + proof
Issuer-->>Wallet: Credential response
Credential Offer Example:
{
"credential_issuer": "https://university.example.edu",
"credential_configuration_ids": ["UniversityDegree_SDJWT"],
"grants": {
"urn:ietf:params:oauth:grant-type:pre-authorized_code": {
"pre-authorized_code": "SplxlOBeZQQYbYS6WxSbIA",
"tx_code": {
"input_mode": "numeric",
"length": 6,
"description": "Enter the PIN sent to your email"
}
}
}
}
Authorization code flow¶
Standard OAuth 2.0 flow where the user authenticates with the authorization server.
sequenceDiagram
autonumber
participant User
participant Wallet
participant AS as Auth Server
participant Issuer
Wallet->>AS: Authorization request
AS->>User: Login page
User->>AS: Authenticate
AS-->>Wallet: Authorization code
Wallet->>AS: Exchange code for token
AS-->>Wallet: access_token + c_nonce
Wallet->>Issuer: Credential request + proof
Issuer-->>Wallet: Credential response
Proof of possession¶
The holder must prove they control the key that will be bound to the credential. This is done via a proof JWT.
Proof JWT structure¶
Header:
{
"typ": "openid4vci-proof+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "...",
"y": "..."
}
}
Payload:
{
"iss": "did:example:holder123",
"aud": "https://university.example.edu",
"nonce": "tZignsnFbp",
"iat": 1701234567
}
| Claim | Required | Purpose |
|---|---|---|
iss |
Optional | Holder's identifier (DID or client_id) |
aud |
Yes | Issuer's credential_issuer URL |
nonce |
Yes | c_nonce from token response |
iat |
Yes | Token issuance time |
Proof validation rules¶
- Type check: Header
typmust beopenid4vci-proof+jwt - Algorithm check:
algmust be supported by Issuer - Nonce check:
noncemust match current valid c_nonce - Audience check:
audmust match Issuer's credential_issuer URL - Freshness check:
iatmust be within acceptable window - Signature check: Signature must be valid for the key in
jwkor resolved fromkid
Code example: requesting a credential¶
The examples below show the essential structure. For complete, runnable code, see the OID4VCI sample.
Wallet side — building a credential request¶
using SdJwt.Net.Oid4Vci.Models;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
// 1. Parse the credential offer
var offer = JsonSerializer.Deserialize<CredentialOffer>(offerJson);
// 2. Exchange pre-authorized code for access token
var tokenResponse = await ExchangePreAuthCodeAsync(
offer.CredentialIssuer,
preAuthorizedCode: offer.GetPreAuthorizedCodeGrant()?.PreAuthorizedCode,
txCode: userEnteredPin
);
// 3. Create proof JWT
var proofJwt = CreateProofJwt(
holderPrivateKey,
audience: offer.CredentialIssuer,
nonce: tokenResponse.CNonce
);
// 4. Build credential request
var request = new CredentialRequest
{
Format = Oid4VciConstants.SdJwtVcFormat, // "dc+sd-jwt"
Vct = "https://credentials.university.example.edu/degree",
Proof = new CredentialProof
{
ProofType = Oid4VciConstants.ProofTypes.Jwt,
Jwt = proofJwt
}
};
// 5. Send request
var credential = await RequestCredentialAsync(
credentialEndpoint: $"{offer.CredentialIssuer}/credential",
accessToken: tokenResponse.AccessToken,
request: request
);
Creating the proof JWT¶
private string CreateProofJwt(
SecurityKey holderKey,
string audience,
string nonce)
{
var holderJwk = JsonWebKeyConverter.ConvertFromSecurityKey(holderKey);
var header = new JwtHeader(
new SigningCredentials(holderKey, SecurityAlgorithms.EcdsaSha256))
{
["typ"] = "openid4vci-proof+jwt",
["jwk"] = new Dictionary<string, object>
{
["kty"] = holderJwk.Kty,
["crv"] = holderJwk.Crv,
["x"] = holderJwk.X,
["y"] = holderJwk.Y
}
};
var payload = new JwtPayload
{
{ "aud", audience },
{ "nonce", nonce },
{ "iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds() }
};
var token = new JwtSecurityToken(header, payload);
return new JwtSecurityTokenHandler().WriteToken(token);
}
Code example: issuing a credential¶
Issuer side — validating request and issuing¶
using SdJwt.Net.Oid4Vci.Issuer;
using SdJwt.Net.Vc.Issuer;
using SdJwt.Net.Vc.Models;
// 1. Validate the proof JWT (CNonceValidator is a static class)
ProofValidationResult proofResult;
try
{
proofResult = CNonceValidator.ValidateProof(
jwtString: request.Proof.Jwt,
expectedCNonce: currentNonce,
expectedIssuerUrl: "https://university.example.edu",
clockSkew: TimeSpan.FromMinutes(5));
}
catch (ProofValidationException ex)
{
return BadRequest(new { error = "invalid_proof", error_description = ex.Message });
}
// 2. Extract holder's public key from validated proof
var holderPublicKey = proofResult.PublicKey;
// 3. Build the credential payload
var payload = new SdJwtVcPayload
{
Issuer = "https://university.example.edu",
Subject = "did:example:student123",
IssuedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
ExpiresAt = DateTimeOffset.UtcNow.AddYears(10).ToUnixTimeSeconds(),
AdditionalData = new Dictionary<string, object>
{
["student_name"] = "Alice Smith",
["degree"] = "Bachelor of Science",
["major"] = "Computer Science",
["graduation_date"] = "2024-05-15",
["gpa"] = 3.85
}
};
// 4. Issue with selective disclosure options
var vcIssuer = new SdJwtVcIssuer(issuerSigningKey, SecurityAlgorithms.EcdsaSha256);
var options = new SdIssuanceOptions
{
DisclosureStructure = new
{
student_name = true, // Selectively disclosable
degree = false, // Always visible
major = true, // Selectively disclosable
graduation_date = false,// Always visible
gpa = true // Selectively disclosable
}
};
var result = vcIssuer.Issue(
vct: "https://credentials.university.example.edu/degree",
payload: payload,
options: options,
holderPublicKey: holderPublicKey
);
// 5. Return credential response
return Ok(new CredentialResponse
{
Format = Oid4VciConstants.SdJwtVcFormat,
Credential = result.Issuance,
CNonce = GenerateNewNonce(),
CNonceExpiresIn = 300
});
Deferred issuance¶
When credential issuance takes time (e.g., background checks), the issuer returns an acceptance_token instead of the credential.
{
"acceptance_token": "eyJhbG....",
"c_nonce": "fGFF7UkhLA"
}
The Wallet polls the deferred credential endpoint:
// Poll for credential
var deferredResponse = await PollDeferredCredentialAsync(
deferredEndpoint: $"{issuer}/deferred-credential",
accessToken: tokenResponse.AccessToken,
acceptanceToken: initialResponse.AcceptanceToken
);
if (deferredResponse.Credential != null)
{
// Credential is ready
await StoreCredential(deferredResponse.Credential);
}
else
{
// Still processing - retry later
await Task.Delay(TimeSpan.FromSeconds(deferredResponse.Interval ?? 5));
}
Implementation references¶
| Component | File | Description |
|---|---|---|
| Constants | Oid4VciConstants.cs | Protocol constants |
| Credential offer | CredentialOffer.cs | Offer model |
| Credential request | CredentialRequest.cs | Request model |
| Credential response | CredentialResponse.cs | Response model |
| Token response | TokenResponse.cs | Token exchange model |
| Nonce validator | CNonceValidator.cs | Proof validation |
| Package overview | README.md | Quick start |
| Sample code | OpenId4VciExample.cs | Working examples |
Beginner pitfalls to avoid¶
1. Reusing c_nonce values¶
Each c_nonce should be single-use and tracked server-side.
// Track nonce usage
if (await nonceStore.IsUsedAsync(proof.Nonce))
{
return BadRequest(new { error = "invalid_proof", error_description = "nonce already used" });
}
await nonceStore.MarkUsedAsync(proof.Nonce);
2. Ignoring proof audience validation¶
Validate that aud matches your credential_issuer URL, not just the nonce.
// WRONG
if (proof.Nonce == expectedNonce) { /* proceed */ }
// RIGHT
if (proof.Nonce == expectedNonce && proof.Aud == "https://my-issuer.example.com") { /* proceed */ }
3. Missing tx_code validation for pre-authorized flow¶
When tx_code is specified in the offer, the wallet must send the user-entered code.
// Validate tx_code if required
var grant = offer.GetPreAuthorizedCodeGrant();
if (grant?.TransactionCode != null)
{
if (string.IsNullOrEmpty(userProvidedTxCode))
{
return BadRequest("Transaction code required");
}
if (!ValidateTxCode(userProvidedTxCode, grant.TransactionCode))
{
return BadRequest("Invalid transaction code");
}
}
4. Mixing credential_definition and credential_identifier¶
These fields are mutually exclusive in the credential request:
// WRONG
var request = new CredentialRequest
{
CredentialDefinition = new { ... },
CredentialIdentifier = "UniversityDegree_SDJWT" // Cannot have both!
};
// RIGHT - use one or the other
var request = new CredentialRequest
{
CredentialIdentifier = "UniversityDegree_SDJWT"
};
Frequently asked questions¶
Q: What is the difference between OID4VCI and OID4VP?¶
A: OID4VCI is for credential issuance (Issuer to Wallet). OID4VP is for credential presentation (Wallet to Verifier). They are complementary protocols in the credential lifecycle.
Q: When should I use pre-authorized vs authorization code flow?¶
A: Use pre-authorized when the user has already been authenticated elsewhere (e.g., completed registration, in-person verification). Use authorization code when the user needs to authenticate with the authorization server as part of the issuance flow.
Q: Can I issue multiple credentials in one request?¶
A: Yes, use the batch credential endpoint. The Wallet sends multiple proof JWTs and receives multiple credentials.
Q: Why does the Issuer send a new c_nonce in the credential response?¶
A: This allows the Wallet to immediately request another credential without a new token exchange. The new c_nonce is valid for subsequent requests using the same access token.
Q: How do I handle credential format compatibility?¶
A: Check the Issuer's metadata for credential_configurations_supported to see available formats. Use dc+sd-jwt for new implementations; vc+sd-jwt is legacy.
Related concepts¶
- Verifiable Credential - Structure of issued credentials
- OID4VP - Presenting credentials after issuance
- SD-JWT - Base selective disclosure format