Skip to main content
Version: v0.25.0 (Latest)

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.

val 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

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:

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:

PropertyTypeDescription
issuerIdentifierStringThe issuer's HTTPS URL, used as the credential_issuer in offers
credentialConfigurationsMapSupported credential types with their format, claims, and display info
authorizationServersList<String>?Authorization server identifiers (if different from issuer)
displayList<DisplayProperties>?Issuer display information (name, logo, locale)
signingKeyManagedIdentifierOptsOrResult?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 PropertyTypeDefaultDescription
enabledBooleanfalseWhether IAE is required for this credential
interaction-typeStringopenid4vp_presentationThe interaction URN the AS will request
dcql-query-idString?nullDCQL query to use for VP presentation challenges

Two interaction types are supported:

TypeURNDescription
OID4VP Presentationurn:openid:dcp:iae:openid4vp_presentationHolder presents an existing credential
Redirect to Weburn:openid:dcp:iae:redirect_to_webHolder 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:

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
)
)

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.

val signedMetadata = issuer.buildSignedIssuerMetadata(
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.

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

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.

// 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

The issuer performs several validations automatically:

  1. Access token validation via the AuthorizationServerBridge
  2. Proof verification: checks the JWT signature, nonce validity, and issuer URL
  3. Credential configuration matching: ensures the request matches a supported configuration
  4. 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.

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))
}
}

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.

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())
))
}
}

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.

// 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

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.

issuer.handleNotification(
HandleNotificationArgs(
accessToken = bearerToken,
notification = CredentialNotification(
notificationId = notificationId,
event = CredentialNotificationEvent.CREDENTIAL_ACCEPTED
)
)
)

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.

StorePurpose
CredentialOfferStoreStores credential offers with TTL for expiration
CredentialNonceStoreManages single-use nonces with TTL
CredentialIssuanceSessionStoreTracks the full issuance session lifecycle
DeferredCredentialStoreHolds credentials waiting for deferred retrieval
NotificationStateStoreEnsures 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
)