OID4VCI Issuer
The OID4VCI issuer component enables credential providers to issue verifiable credentials to holders. This guide covers setting up issuer metadata using the DSL, configuring issuance policies, handling credential requests, and extending the issuer with custom format handlers and attribute contributors.
Obtaining the Issuer Service
The issuer service is available through the IDK's dependency graph once the OID4VCI module is loaded. All issuer operations (creating offers, handling credential requests, deferred issuance) go through this service.
- Android/Kotlin
- iOS/Swift
val issuer: Oid4vciIssuerService = session.graph.oid4vciIssuerService
let issuer: Oid4vciIssuerService = session.graph.oid4vciIssuerService
Issuer Metadata DSL
Issuer metadata is the JSON document that wallets fetch from /.well-known/openid-credential-issuer to discover what credentials you offer, which endpoints to call, and what proof types you accept. Without it, a holder has no way to start an issuance flow.
The IDK provides a Kotlin DSL for building issuer metadata. The DSL automatically derives standard endpoints from the issuer URL and provides a type-safe way to define credential configurations, display properties, and encryption settings.
Basic Metadata
- Android/Kotlin
val metadata = issuerMetadata("https://issuer.example.com") {
authorizationServer("https://auth.example.com")
credentialConfiguration("IdentityCredential", CredentialFormat.SD_JWT_DC) {
vct = "https://issuer.example.com/identity"
scope = "identity"
bindingMethod("jwk")
signingAlg(JwaAlgorithm.ES256)
proofType(ProofType.JWT, JwaAlgorithm.ES256)
display {
name = "Identity Credential"
locale = "en-US"
logo("https://issuer.example.com/logo.png", "Issuer Logo")
backgroundColor = "#1a365d"
textColor = "#ffffff"
}
}
display {
name = "Example Issuer"
locale = "en-US"
}
}
The issuerMetadata function takes the issuer URL and derives the standard endpoints automatically:
- Credential endpoint:
{issuerUrl}/credential - Deferred credential endpoint:
{issuerUrl}/credential_deferred - Notification endpoint:
{issuerUrl}/notification - Nonce endpoint:
{issuerUrl}/nonce
You can override any endpoint explicitly if your deployment uses different paths.
Multiple Credential Configurations
An issuer typically offers several credential types. Each configuration defines the format, claims, proof requirements, and display properties for one type of credential:
- Android/Kotlin
val metadata = issuerMetadata("https://university.example.edu") {
authorizationServer("https://auth.university.example.edu")
// SD-JWT credential with selective disclosure
credentialConfiguration("UniversityDegree", CredentialFormat.SD_JWT_DC) {
vct = "https://credentials.example.com/university_degree"
scope = "degree"
bindingMethods("jwk", "did:key")
signingAlgs(JwaAlgorithm.ES256, JwaAlgorithm.ES384)
proofType(ProofType.JWT, JwaAlgorithm.ES256)
display {
name = "University Degree"
locale = "en-US"
logo("https://university.example.edu/logo.png")
backgroundColor = "#12107c"
textColor = "#FFFFFF"
}
}
// mDoc credential for mobile driving license
credentialConfiguration("MobileDrivingLicense", CredentialFormat.MSO_MDOC) {
doctype = "org.iso.18013.5.1.mDL"
bindingMethod("jwk")
signingAlg(JwaAlgorithm.ES256)
display {
name = "Mobile Driving License"
locale = "en-US"
}
}
// JWT VC JSON credential with W3C types
credentialConfiguration("MembershipCard", CredentialFormat.JWT_VC_JSON) {
scope = "membership"
signingAlg(JwaAlgorithm.ES256)
credentialDefinition {
type("VerifiableCredential", "MembershipCredential")
}
display {
name = "Membership Card"
locale = "en-US"
}
}
display {
name = "Example University"
locale = "en-US"
}
}
Claim Metadata
Claim metadata tells wallets which attributes a credential contains, whether they are mandatory, and how to display them to the user. The DSL supports two styles of claim metadata, matching the OID4VCI specification versions.
OID4VCI 1.0 style uses map-based claims with claim() directly on the credential configuration:
credentialConfiguration("IdentityCredential", CredentialFormat.SD_JWT_DC) {
vct = "https://issuer.example.com/identity"
claim("given_name") {
mandatory = true
valueType = "string"
display("First Name", "en-US")
}
claim("family_name") {
mandatory = true
valueType = "string"
display("Last Name", "en-US")
}
claim("birth_date") {
mandatory = true
valueType = "string"
display("Date of Birth", "en-US")
}
}
OID4VCI 1.1 style uses path-based claims via credentialMetadata, supporting nested paths and array indices:
credentialConfiguration("IdentityCredential", CredentialFormat.SD_JWT_DC) {
vct = "https://issuer.example.com/identity"
credentialMetadata {
display {
name = "Identity Credential"
locale = "en-US"
}
claim("given_name") {
mandatory = true
display("First Name", "en-US")
}
claim("address", "street_address") {
mandatory = false
display("Street Address", "en-US")
}
claim("nationalities", 0) // Array index path
}
}
Response Encryption
Some deployments require the credential response to be encrypted so it cannot be read by intermediaries. You can configure encryption at the issuer level (applying to all credentials) or override it per credential configuration.
Configure credential response encryption at the issuer level or per credential configuration:
val metadata = issuerMetadata("https://issuer.example.com") {
// Issuer-level encryption settings
credentialResponseEncryption {
alg("ECDH-ES", "RSA-OAEP")
enc("A256GCM")
encryptionRequired = true
}
credentialConfiguration("SensitiveCredential", CredentialFormat.SD_JWT_DC) {
// Per-credential encryption override
credentialResponseEncryption {
alg("ECDH-ES")
enc("A256GCM")
zip("DEF")
encryptionRequired = true
}
}
}
Batch Issuance
Batch issuance lets a holder request several credentials in one round trip instead of making separate requests for each one. The maxBatchSize value sets the upper bound on how many credentials the issuer will produce per request.
val metadata = issuerMetadata("https://issuer.example.com") {
batchCredentialIssuance(maxBatchSize = 10)
// ...
}
Configuration-Driven Setup
As an alternative to the DSL, the issuer can be configured entirely through YAML configuration or environment variables, using the IDK's hierarchical configuration system. This is useful for deployments where the issuer configuration is managed externally.
Issuer Configuration Provider
The Oid4vciIssuerConfigProvider interface defines what the issuer needs at runtime:
| Property | Type | Description |
|---|---|---|
issuerIdentifier | String | The issuer's HTTPS URL, used as the credential_issuer in offers |
credentialConfigurations | Map | Supported credential types with their format, claims, and display info |
authorizationServers | List<String>? | Authorization server identifiers (if different from issuer) |
display | List<DisplayProperties>? | Issuer display information (name, logo, locale) |
signingKey | ManagedIdentifierOptsOrResult? | Key for signing metadata JWTs |
The IDK ships two implementations:
ConfigDrivenOid4vciIssuerConfigProvider reads from YAML, environment variables, and the IDK settings store. It uses the DSL builders internally to construct the credential configurations from flat properties:
oid4vci:
issuer:
identifier: https://issuer.example.com
authorizationServers: https://auth.example.com
credentialConfigurationIds: UniversityDegree,MembershipCard
display:
name: Example University
locale: en-US
credentials:
"[UniversityDegree]":
format: dc+sd-jwt
scope: degree
signingAlgorithms: ES256,ES384
bindingMethods: jwk,did:key
proofTypes:
jwt:
signingAlgorithms: ES256
display:
name: University Degree
locale: en-US
The same configuration can be provided via environment variables:
OID4VCI_ISSUER_IDENTIFIER=https://issuer.example.com
OID4VCI_ISSUER_CREDENTIALS_UNIVERSITYDEGREE_FORMAT=dc+sd-jwt
OID4VCI_ISSUER_CREDENTIALS_UNIVERSITYDEGREE_SCOPE=degree
DesignBackedOid4vciIssuerConfigProvider reads from the credential design store, deriving credential configurations from stored designs. This is useful when you manage credential metadata through the design system and want the issuer metadata to stay in sync automatically.
To wire a config provider, register it in your DI module:
@ContributesTo(AppScope::class)
interface MyIssuerModule {
@Provides
fun provideIssuerConfigProvider(
impl: ConfigDrivenOid4vciIssuerConfigProvider,
): Oid4vciIssuerConfigProvider = impl
}
Per-Credential Issuance Policy
Each credential configuration has its own issuance policy that controls grant types, nonce behavior, deferred issuance, and encryption requirements:
oid4vci:
issuer:
credentials:
"[IdentityCredential]":
iae:
enabled: false
interaction-type: urn:openid:dcp:iae:openid4vp_presentation
dcql-query-id: national-id-presentation
grants:
pre-authorized-code:
allowed: true
tx-code-required: true # Require a PIN for issuance
authorization-code:
allowed: true
nonce:
ttl-seconds: 300 # Nonce validity (default: 5 min)
deferred:
retry-interval-seconds: 5 # Suggested polling interval
encryption:
response-required: false # Require encrypted responses
This policy is evaluated at the credential configuration level, allowing different security requirements for different credential types. For instance, a government identity credential might require transaction codes and response encryption, while a loyalty card might allow pre-authorized issuance without additional security.
Interactive Authorization Exchange (IAE)
IAE is an OID4VCI 1.1 mechanism that adds an interactive verification step before issuing a credential. The issuer's authorization server challenges the holder to perform an additional action (typically presenting an existing credential via OID4VP) before granting an authorization code.
This creates a "present-to-obtain" pattern. For example, a university issuer can require holders to present their government-issued national ID before receiving a degree credential. The IAE policy is configured per credential configuration:
oid4vci:
issuer:
credentials:
"[UniversityDegree]":
iae:
enabled: true
interaction-type: urn:openid:dcp:iae:openid4vp_presentation
dcql-query-id: national-id-presentation
"[GovernmentPID]":
iae:
enabled: true
interaction-type: urn:openid:dcp:iae:redirect_to_web
| IAE Property | Type | Default | Description |
|---|---|---|---|
enabled | Boolean | false | Whether IAE is required for this credential |
interaction-type | String | openid4vp_presentation | The interaction URN the AS will request |
dcql-query-id | String? | null | DCQL query to use for VP presentation challenges |
Two interaction types are supported:
| Type | URN | Description |
|---|---|---|
| OID4VP Presentation | urn:openid:dcp:iae:openid4vp_presentation | Holder presents an existing credential |
| Redirect to Web | urn:openid:dcp:iae:redirect_to_web | Holder authenticates via browser redirect |
When interaction-type is openid4vp_presentation, the dcql-query-id specifies which DCQL query the authorization server uses to build the OID4VP request. This lets you define exactly what credential the holder must present and which claims are required.
The IAE policy is resolved at runtime through the CredentialIssuancePolicyResolver, which reads the configuration for each credential configuration ID and determines whether IAE is needed and what interaction type to use. See the Holder documentation for the holder-side flow.
Publishing Issuer Metadata
Whether you use the DSL or configuration-driven setup, the metadata needs to be served from the .well-known/openid-credential-issuer endpoint. Wallets will HTTP GET this URL when they first encounter your issuer identifier, so it must be publicly reachable. You can also build it programmatically:
- Android/Kotlin
- iOS/Swift
val metadata = issuer.buildIssuerMetadata(
BuildIssuerMetadataArgs(
issuerIdentifier = "https://issuer.example.com",
baseUrl = "https://issuer.example.com",
authorizationServers = listOf("https://auth.example.com"),
credentialConfigurations = configProvider.credentialConfigurations,
display = configProvider.display
)
)
let metadata = try await issuer.buildIssuerMetadata(
args: BuildIssuerMetadataArgs(
issuerIdentifier: "https://issuer.example.com",
baseUrl: "https://issuer.example.com",
authorizationServers: ["https://auth.example.com"],
credentialConfigurations: configProvider.credentialConfigurations,
display: configProvider.display
)
)
The metadata object is serializable and can be served directly from your HTTP endpoint.
Signed Metadata
For additional trust, the issuer can sign its metadata as a JWT. Holders that enable requireVerifiedSignedMetadata will verify this signature before proceeding.
- Android/Kotlin
- iOS/Swift
val signedMetadata = issuer.buildSignedIssuerMetadata(
BuildSignedIssuerMetadataArgs(
metadata = metadata,
signingKey = issuerSigningKey
)
)
// signedMetadata.jwt - Include as "signed_metadata" in the metadata response
let signedMetadata = try await issuer.buildSignedIssuerMetadata(
args: BuildSignedIssuerMetadataArgs(
metadata: metadata,
signingKey: issuerSigningKey
)
)
// signedMetadata.jwt - Include as "signed_metadata" in the metadata response
Creating Credential Offers
A credential offer is a JSON object that tells a holder's wallet "here are the credentials I can issue you, and here is how to get an access token to claim them." The holder typically receives the offer as a openid-credential-offer:// URI, either scanned from a QR code or opened via a deep link.
To initiate issuance, the issuer creates a credential offer and delivers it to the holder. The offer specifies which credentials are available and how the holder can obtain an access token.
- Android/Kotlin
- iOS/Swift
val offerArgs = createOfferArgs {
issuerId("https://issuer.example.com")
credentials("IdentityCredential")
preAuthorizedCodeGrant(txCodeRequired = true)
offerTtl(600) // Offer expires in 10 minutes
}
val createdOffer = issuer.createCredentialOffer(offerArgs)
// Deliver the offer to the holder
val offerUri = createdOffer.offerUri // openid-credential-offer://...
val qrCodeContent = offerUri // Encode as QR code
val txCode = createdOffer.txCode // Send to holder via SMS/email
let createdOffer = try await issuer.createCredentialOffer(
args: CreateCredentialOfferArgs(
issuerId: "https://issuer.example.com",
credentialConfigurationIds: ["IdentityCredential"],
preAuthorizedCodeGrant: true,
txCodeRequired: true, // Require PIN entry
offerTtlSeconds: 600 // Offer expires in 10 minutes
)
)
// Deliver the offer to the holder
let offerUri = createdOffer.offerUri // openid-credential-offer://...
let qrCodeContent = offerUri // Encode as QR code
let txCode = createdOffer.txCode // Send to holder via SMS/email
The issuer can also pre-seed attributes into the offer using the attributes block. These are stored in the issuance session and made available to the credential format handler during issuance:
val offerArgs = createOfferArgs {
issuerId("https://issuer.example.com")
credentials("IdentityCredential")
preAuthorizedCodeGrant(txCodeRequired = true)
attributes {
put("given_name", JsonPrimitive("Jane"))
put("family_name", JsonPrimitive("Doe"))
}
}
Handling Credential Requests
This is the server-side handler for your /credential endpoint. When a holder redeems an offer, their wallet sends a credential request containing an access token and a proof-of-possession JWT. The issuer validates the access token, verifies the proof of possession, resolves the credential attributes, and issues the credential using the appropriate format handler.
- Android/Kotlin
- iOS/Swift
// In your credential endpoint handler
val credentialResponse = issuer.handleCredentialRequest(
HandleCredentialRequestArgs(
accessToken = bearerToken,
credentialRequest = request,
issuerIdentifier = "https://issuer.example.com",
credentialConfigurations = metadata.credentialConfigurationsSupported
)
)
// Return the response to the holder
// credentialResponse.credential - The issued credential (or null if deferred)
// credentialResponse.transactionId - For deferred issuance
// credentialResponse.notificationId - For notification tracking
// In your credential endpoint handler
let credentialResponse = try await issuer.handleCredentialRequest(
args: HandleCredentialRequestArgs(
accessToken: bearerToken,
credentialRequest: request,
issuerIdentifier: "https://issuer.example.com",
credentialConfigurations: metadata.credentialConfigurationsSupported
)
)
// Return the response to the holder
// credentialResponse.credential - The issued credential (or null if deferred)
// credentialResponse.transactionId - For deferred issuance
// credentialResponse.notificationId - For notification tracking
The issuer performs several validations automatically:
- Access token validation via the
AuthorizationServerBridge - Proof verification: checks the JWT signature, nonce validity, and issuer URL
- Credential configuration matching: ensures the request matches a supported configuration
- Format handler dispatch: delegates to the appropriate
CredentialFormatHandler
Extension Points
The issuer module provides three extension points (SPIs) that let you customize credential issuance without modifying the core protocol logic.
Credential Format Handlers
A CredentialFormatHandler is responsible for building the actual credential in a specific format. It receives the resolved claims and the holder's binding key, and returns the signed credential bytes. The IDK ships with handlers for jwt_vc_json, mso_mdoc, and dc+sd-jwt, but you can register additional ones.
- Android/Kotlin
- iOS/Swift
class CustomFormatHandler : CredentialFormatHandler {
override val supportedFormat = "custom+jwt"
override suspend fun canHandle(
request: CredentialRequest,
configuration: CredentialConfigurationSupported
): Boolean {
return request.format == supportedFormat ||
configuration.format == supportedFormat
}
override suspend fun issueCredential(
request: CredentialRequest,
context: IssuanceContext
): IdkResult<CredentialEnvelope, IdkError> {
val credential = buildCustomCredential(
claims = context.resolvedAttributes,
holderKey = context.verifiedProof.holderKey,
signingKey = context.issuerSigningKey
)
return IdkResult.Ok(CredentialEnvelope(credential))
}
}
class CustomFormatHandler: CredentialFormatHandler {
let supportedFormat = "custom+jwt"
func canHandle(
request: CredentialRequest,
configuration: CredentialConfigurationSupported
) async -> Bool {
return request.format == supportedFormat ||
configuration.format == supportedFormat
}
func issueCredential(
request: CredentialRequest,
context: IssuanceContext
) async throws -> IdkResult<CredentialEnvelope, IdkError> {
let credential = buildCustomCredential(
claims: context.resolvedAttributes,
holderKey: context.verifiedProof.holderKey,
signingKey: context.issuerSigningKey
)
return .ok(CredentialEnvelope(credential))
}
}
Credential Attribute Contributors
A CredentialAttributeContributor is called during issuance to provide the claims that go into the credential. This is where you connect your business logic, such as querying a user database, calling an external identity provider, or enriching attributes from a verification result.
- Android/Kotlin
- iOS/Swift
class UserDatabaseAttributeContributor(
private val userService: UserService
) : CredentialAttributeContributor {
override suspend fun contribute(
session: IssuanceSession,
tokenContext: ValidatedTokenContext,
credentialConfigurationId: String
): IdkResult<Map<String, JsonElement>, IdkError> {
val userId = tokenContext.subject
val user = userService.findById(userId)
?: return IdkResult.Err(IdkError("User not found"))
return IdkResult.Ok(mapOf(
"given_name" to JsonPrimitive(user.firstName),
"family_name" to JsonPrimitive(user.lastName),
"birth_date" to JsonPrimitive(user.dateOfBirth.toString())
))
}
}
class UserDatabaseAttributeContributor: CredentialAttributeContributor {
private let userService: UserService
init(userService: UserService) {
self.userService = userService
}
func contribute(
session: IssuanceSession,
tokenContext: ValidatedTokenContext,
credentialConfigurationId: String
) async throws -> IdkResult<[String: JsonElement], IdkError> {
let userId = tokenContext.subject
guard let user = userService.findById(userId) else {
return .err(IdkError("User not found"))
}
return .ok([
"given_name": JsonPrimitive(user.firstName),
"family_name": JsonPrimitive(user.lastName),
"birth_date": JsonPrimitive(user.dateOfBirth.description)
])
}
}
Multiple attribute contributors can be registered. Their results are merged, with later contributors overriding earlier ones for overlapping keys.
Authorization Server Bridge
The Oid4vciAuthorizationServerBridge connects the issuer to your OAuth 2.0 authorization server. It validates access tokens and manages authorization sessions. You must implement this bridge to integrate with your existing auth infrastructure.
The bridge has two key responsibilities:
- Token validation: Validate the access token from the credential request, extract the subject, scopes, and any authorization details
- Session management: Create and manage authorization sessions for the authorization code flow
Deferred Credential Handling
Not every credential can be issued on the spot. If your issuance pipeline involves manual approval, background checks, or asynchronous signing, you can defer the response. When the issuer cannot produce a credential immediately, it returns a transactionId instead. The holder polls the deferred endpoint with that ID, and the issuer responds with the credential once it is ready.
- Android/Kotlin
- iOS/Swift
// In your deferred credential endpoint handler
val response = issuer.handleDeferredCredentialRequest(
HandleDeferredCredentialRequestArgs(
accessToken = bearerToken,
deferredRequest = DeferredCredentialRequest(
transactionId = requestedTransactionId
)
)
)
// response.credential - The credential if ready, null if still pending
// response.transactionId - Same ID if still pending
// In your deferred credential endpoint handler
let response = try await issuer.handleDeferredCredentialRequest(
args: HandleDeferredCredentialRequestArgs(
accessToken: bearerToken,
deferredRequest: DeferredCredentialRequest(
transactionId: requestedTransactionId
)
)
)
// response.credential - The credential if ready, null if still pending
// response.transactionId - Same ID if still pending
Deferred credentials are managed through the DeferredCredentialStore. When the credential is ready (e.g., after an asynchronous approval process), store it with the transaction ID so the next polling request returns it.
Notification Handling
After a holder receives a credential, it can send a notification back to the issuer indicating whether it was accepted, rejected, or deleted. The notification endpoint receives these events, allowing the issuer to track whether credentials were successfully stored or if there were errors.
- Android/Kotlin
- iOS/Swift
issuer.handleNotification(
HandleNotificationArgs(
accessToken = bearerToken,
notification = CredentialNotification(
notificationId = notificationId,
event = CredentialNotificationEvent.CREDENTIAL_ACCEPTED
)
)
)
try await issuer.handleNotification(
args: HandleNotificationArgs(
accessToken: bearerToken,
notification: CredentialNotification(
notificationId: notificationId,
event: .credentialAccepted
)
)
)
Notifications are processed idempotently through the NotificationStateStore, so duplicate deliveries from holder retries are handled gracefully.
Issuer Stores
The issuer module uses several stores to persist state. All stores are backed by the IDK's key-value store abstraction and can be backed by any storage provider.
| Store | Purpose |
|---|---|
CredentialOfferStore | Stores credential offers with TTL for expiration |
CredentialNonceStore | Manages single-use nonces with TTL |
CredentialIssuanceSessionStore | Tracks the full issuance session lifecycle |
DeferredCredentialStore | Holds credentials waiting for deferred retrieval |
NotificationStateStore | Ensures idempotent notification processing |
Data Types
These are the primary data classes you will encounter when working with the issuer API. They are listed here for quick reference.
CreatedCredentialOffer
data class CreatedCredentialOffer(
val offerId: String, // Unique identifier for the offer
val offer: CredentialOffer, // The offer object
val offerUri: String, // URI for QR code / deep link
val txCode: String? = null // Transaction code (if required)
)
CredentialConfigurationSupported
data class CredentialConfigurationSupported(
val format: String, // jwt_vc_json, mso_mdoc, dc+sd-jwt
val scope: String? = null, // OAuth scope
val cryptographicBindingMethodsSupported: List<String>?, // e.g., ["jwk", "did"]
val credentialSigningAlgValuesSupported: List<String>?, // e.g., ["ES256"]
val proofTypesSupported: Map<String, ProofTypeSupported>?,
val display: List<DisplayProperties>? = null,
val claims: Map<String, ClaimMetadata>? = null,
val vct: String? = null, // For SD-JWT
val doctype: String? = null // For mso_mdoc
)
IssuanceContext
data class IssuanceContext(
val session: IssuanceSession,
val verifiedProof: VerifiedProof, // Validated proof with holder key
val resolvedAttributes: Map<String, JsonElement>, // From attribute contributors
val credentialConfiguration: CredentialConfigurationSupported,
val issuerSigningKey: ManagedIdentifierOptsOrResult
)