Issuer
Learn how to implement the issuance of verifiable credentials from a server to a holder app using the Multipaz SDK in a secure and standards-compliant way, following the OpenID4VCI protocol. OpenID4VCI stands for OpenID Connect for Verifiable Credential Issuance, which defines an OAuth-protected API for the issuance of Verifiable Credentials.
What you’ll implement:
- OpenID4VCI credential offer handling (via app links, or custom URL schemes).
- Minimal “wallet back-end” for demo purposes to complete attestation and OAuth steps.
- A basic UI that guides users through authorization and receives issued credentials.
Dependencies
Add Ktor HTTP client for network calls (core + platform engines).
Update libs.versions.toml:
[versions]
ktor = "2.3.13"
[libraries]
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
Refer to this libs.versions.toml code for the complete example.
Update app/build.gradle.kts:
androidMain.dependencies {
// ...
implementation(libs.ktor.client.android)
}
commonMain.dependencies {
// ...
implementation(libs.ktor.client.core)
// CIO for JVM/Android
implementation(libs.ktor.client.cio)
}
iosMain.dependencies {
// Darwin engine for iOS in iosMain
implementation(libs.ktor.client.darwin)
}
Refer to this build.gradle.kts code for the complete example.
Android: Permissions and Custom URI Schemes
Issuance needs internet access and deep link handling for:
- Credential offers (e.g.,
openid-credential-offer://) - Wallet redirect/callback (custom or HTTPS app link)
Update androidMain/AndroidManifest.xml:
<!-- Networking -->
<uses-permission android:name="android.permission.INTERNET" />
<application ...>
<activity
android:name=".MainActivity"
android:enableOnBackInvokedCallback="true"
android:exported="true"
android:launchMode="singleInstance">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- 1) Custom URI scheme for wallet redirect (used in this sample) -->
<!-- Example redirect: get-started-app://landing/?state=... -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="get-started-app" />
<data android:host="landing" />
</intent-filter>
<!-- 2) Credential Offer schemes (OpenID4VCI, HAIP) -->
<!-- Allows scanning/handling credential offer URLs -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="openid-credential-offer" />
<data android:scheme="haip" />
<data android:host="*" />
</intent-filter>
<!-- Alternative (recommended for production): HTTPS App Links
Requires .well-known/assetlinks.json on your domain.
See comments in the patch for details. -->
<!--
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="getstarted.multipaz.org"
android:pathPattern="/landing/.*"
android:scheme="https" />
</intent-filter>
-->
</activity>
</application>
Refer to this AndroidManifest.xml code for the complete example.
Choosing a link strategy
There are two ways to route back to your app after authorization:
- Custom URI scheme (used in this sample)
- Example:
get-started-app://landing/?state=... - Pros: Simple to set up for demos and codelabs; no server config needed.
- Cons: Cannot be initiated by the server; when multiple apps register the same scheme, Android may present a chooser or misroute the intent. This can conflict with scenarios where Test App and Getting Started Sample App are both installed.
- Example:
- HTTPS App Links (preferred for production)
- Pros: Verifiable, secure, server-initiated, avoids intent-misrouting when multiple apps are installed.
- Requirements:
- Host an Digital Asset Links file at
https://<your-domain>/.well-known/assetlinks.jsoncontaining your Android package name and signing cert SHA-256. - Add an Android
VIEWintent filter withandroid:autoVerify="true"for your HTTPS domain and path.
- Host an Digital Asset Links file at
Recommendation: Use custom schemes for development, switch to HTTPS App Links for production.
Deep Link Handling in Activity
Handle incoming URLs (Intents) in MainActivity and forward them to the app logic:
androidMain/MainActivity.kt
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// ...
setContent { /* ... */ }
handleIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent) {
if (intent.action == Intent.ACTION_VIEW) {
val url = intent.dataString ?: return
lifecycle.coroutineScope.launch {
val app = App.getInstance()
app.init()
app.handleUrl(url)
}
}
}
}
Refer to this MainActivity.kt code for the complete example.
Initialize Issuance in App
- Add provisioning fields, initialize ProvisioningModel & ProvisioningSupport.
// ...
class App {
// ...
lateinit var provisioningModel: ProvisioningModel
lateinit var provisioningSupport: ProvisioningSupport
// Channel for incoming credential offer URIs
private val credentialOffers = Channel<String>()
// ...
suspend fun init() {
if (!isInitialized) {
// ... existing initializations
// ... to initialize provisioningModel
provisioningModel = ProvisioningModel(
documentStore = documentStore,
secureArea = secureArea,
httpClient = io.ktor.client.HttpClient() {
followRedirects = false
},
promptModel = promptModel,
documentMetadataInitializer = ::initializeDocumentMetadata
)
provisioningSupport = ProvisioningSupport(
storage = storage,
secureArea = secureArea,
)
provisioningSupport.init()
}
}
}
Refer to this initialization code for the complete example.
- Add URL handling for credential offers and app links:
class App {
companion object {
private const val OID4VCI_CREDENTIAL_OFFER_URL_SCHEME = "openid-credential-offer://"
private const val HAIP_URL_SCHEME = "haip://"
private const val ISSUER_URL = "https://issuer.multipaz.org/"
// ...
}
/** Parse URLs from Activity and route them to either provisioning or app-link flow */
fun handleUrl(url: String) {
if (url.startsWith(OID4VCI_CREDENTIAL_OFFER_URL_SCHEME)
|| url.startsWith(HAIP_URL_SCHEME)
) {
val queryIndex = url.indexOf('?')
if (queryIndex >= 0) {
CoroutineScope(Dispatchers.Default).launch {
credentialOffers.send(url)
}
}
} else if (url.startsWith(ProvisioningSupport.APP_LINK_BASE_URL)) {
CoroutineScope(Dispatchers.Default).launch {
provisioningSupport.processAppLinkInvocation(url)
}
}
}
}
Refer to this URL handling code for the complete example.
- Wire the issuance loop and UI switch in
Content()composable:
class App {
@Composable
fun Content() {
// ...
var isProvisioning by remember { mutableStateOf(false) }
val provisioningState = provisioningModel.state.collectAsState().value
// Listen for credential offers and launch OID4VCI flow
LaunchedEffect(true) {
if (!provisioningModel.isActive) {
while (true) {
val credentialOffer = credentialOffers.receive()
provisioningModel.launchOpenID4VCIProvisioning(
offerUri = credentialOffer,
clientPreferences = provisioningSupport.getOpenID4VCIClientPreferences(),
backend = provisioningSupport.getOpenID4VCIBackend()
)
isProvisioning = true
}
}
}
Column(/* ... */) {
if (isProvisioning) {
Provisioning(
provisioningModel = provisioningModel,
waitForRedirectLinkInvocation = { state ->
provisioningSupport.waitForAppLinkInvocation(state)
}
)
Button(onClick = {
provisioningModel.cancel();
isProvisioning = false
}) {
Text(
if (provisioningState is ProvisioningModel.CredentialsIssued)
"Go Back"
else if (provisioningState is ProvisioningModel.Error)
"An Error Occurred\nTry Again"
else
"Cancel"
)
}
} else {
// Existing presentment UI (QR / BLE)...
// ...
// open the issuer site
Button(
modifier = Modifier.padding(16.dp),
onClick = { LocalUriHandler.current.openUri(ISSUER_URL) }
) {
Text("Issue an mDoc from the server\nhttps://issuer.multipaz.org")
}
}
}
}
}
Provisioning UI
Multipaz provides Provisioning Composable that that interacts with the user and drives credential provisioning in the given ProvisioningModel. This composable handles the end-to-end provisioning UI for the attached ProvisioningModel.
Refer to this Provisioning UI code and this button code for the complete example.
- Initialize document metadata for new credentials:
class App {
// Called by ProvisioningModel to set document display attributes
private suspend fun initializeDocumentMetadata(
metadata: AbstractDocumentMetadata,
credentialDisplay: Display,
issuerDisplay: Display
) {
(metadata as DocumentMetadata).setMetadata(
displayName = credentialDisplay.text,
typeDisplayName = credentialDisplay.text,
cardArt = credentialDisplay.logo
?: ByteString(Res.readBytes("drawable/profile.png")), // todo import png
issuerLogo = issuerDisplay.logo,
other = null
)
}
}
Note: You can download profile.png from here.
Refer to this metadata init function code for the complete example.
ProvisioningSupport & OpenID4VCILocalBackend
The sample includes ProvisioningSupport (to imitate OpenID4VCI wallet back-end) and OpenID4VCILocalBackend (an in-app implementation of OpenID4VCIBackend).
OpenID4VCILocalBackend is used to sign:
- Client assertions (for token exchange)
- Wallet attestation JWT
- Key attestation JWT
ProvisioningSupport also coordinates the app-link redirect callback using a simple state→channel map.
Important: This is for development and testing only. Do not embed keys in production apps. In production, implement OpenID4VCIBackend on your server.
Highlights:
ProvisioningSupportmanages app-link OAuth callbacks using a state-channel, and an instance of `:waitForAppLinkInvocation(state)processAppLinkInvocation(url)getOpenID4VCIClientPreferences()getOpenID4VCIBackend()
class ProvisioningSupport(
val storage: Storage,
val secureArea: SecureArea,
) {
companion object {
// Custom URI Scheme used for app redirection in this sample.
const val APP_LINK_SERVER = "get-started-app"
const val APP_LINK_BASE_URL = "$APP_LINK_SERVER://landing/"
// Alternative HTTP App Links (more secure)
// const val APP_LINK_SERVER = "https://getstarted.multipaz.org"
// const val APP_LINK_BASE_URL = "$APP_LINK_SERVER/landing/"
}
// Wait for wallet redirect: state is provided by the issuer during OAuth
private val lock = Mutex()
private val pendingLinksByState = mutableMapOf<String, SendChannel<String>>()
// Instances of backend and client preferences used for provisioning
private lateinit var backend: OpenID4VCIBackend
private lateinit var preferences: OpenID4VCIClientPreferences
suspend fun init() {
this.backend = OpenID4VCILocalBackend()
preferences = OpenID4VCIClientPreferences(
clientId = withContext(RpcAuthClientSession()) {
backend.getClientId()
},
redirectUrl = APP_LINK_BASE_URL,
locales = listOf("en-US"),
signingAlgorithms = listOf(Algorithm.ESP256, Algorithm.ESP384, Algorithm.ESP512)
)
}
suspend fun processAppLinkInvocation(url: String) {
val state = Url(url).parameters["state"] ?: ""
lock.withLock {
pendingLinksByState.remove(state)?.send(url)
}
}
suspend fun waitForAppLinkInvocation(state: String): String {
val channel = Channel<String>(Channel.RENDEZVOUS)
lock.withLock { pendingLinksByState[state] = channel }
return channel.receive()
}
fun getOpenID4VCIClientPreferences(): OpenID4VCIClientPreferences = preferences
fun getOpenID4VCIBackend(): OpenID4VCIBackend = backend
}
You refer to the full ProvisioningSupport file here.
OpenID4VCILocalBackendimplements:createJwtClientAssertion(tokenUrl: String)createJwtWalletAttestation(keyAttestation: KeyAttestation)createJwtKeyAttestation(keyAttestations: List<KeyAttestation>, challenge: String)
class OpenID4VCILocalBackend : OpenID4VCIBackend {
// Sign a JWT client assertion for token endpoint
override suspend fun createJwtClientAssertion(authorizationServerIdentifier: String): String { /* loads JWK, signs JWT */ }
// Sign wallet attestation JWT (draft-ietf-oauth-attestation-based-client-auth)
override suspend fun createJwtWalletAttestation(keyAttestation: KeyAttestation): String { /* signs with attestation key */ }
// Sign key attestation JWT covering ephemeral public keys
override suspend fun createJwtKeyAttestation(
keyAttestations: List<KeyAttestation>,
challenge: String,
userAuthentication: List<String>?,
keyStorage: List<String>?
): String { /* signs with attestation key */ }
companion object {
/* hardcoded JWKs, keys, and client ID */
}
}
You can copy-paste the OpenID4VCILocalBackend file for the complete implementation.
Wallet back end vs Issuer
- Wallet back end (OpenID4VCIBackend)
- Owned and operated by the Wallet App developer.
- Creates signed artifacts the issuer will trust:
- Client assertion (JWT) to authenticate to a token endpoint.
- Wallet attestation (JWT) binding the wallet to attested keys.
- Key attestation (JWT) for ephemeral public keys plus nonce.
- In this sample, it is mocked in-app for development. In production, implement this on your own server.
- Issuer (e.g.,
issuer.multipaz.org)- Operates OpenID4VCI endpoints: Authorization, Token, Credential Issuance, etc.
- Verifies the wallet back end’s signed artifacts and issues credentials to the wallet.
- You can refer to this document and this diagram for more info on the wallet backend
For local testing, the sample loads hardcoded keys (do not ship these in production; move to a backend). These are cached in-memory and used to produce compact JWTs with COSE-encoded signatures.
How issuance works (end-to-end)
- Scan or open a credential offer link like
openid-credential-offer://?credential_offer=... Appdetects credential offers and sends them toProvisioningModel.launchOpenID4VCIProvisioning(...)- The model requests a backend:
- Client assertion JWT
- Wallet attestation JWT
- Key attestation JWT
- User is redirected to the issuer’s authorization page in the browser
- Issuer redirects back to the app via
get-started-app://landing/?state=... ProvisioningSupportwakes the waiting challenge with the invoked redirect URL- The model requests and stores issued credentials in
DocumentStore - The UI lists issued documents
- A credential offer URL is received:
- As an OpenID4VCI link:
openid-credential-offer://?...
- As an OpenID4VCI link:
MainActivityreceives the VIEW intent and callsApp.handleUrl(url)- For credential offers,
Appenqueues the URL tocredentialOffers, which triggers:ProvisioningModel.launchOpenID4VCIProvisioning(...)with client preferences and back-end.
- For OAuth flows, the sample launches the system browser and waits for the app-link callback to the app; the URL is passed back into ProvisioningModel via
AuthorizationResponse.OAuth(...).- This will be an HTTPS app link of the form
https://apps.multipaz.org/landing/?...
- This will be an HTTPS app link of the form
- Once authorized, credentials are issued and stored in the DocumentStore, with
initializeDocumentMetadata(...)setting display metadata.
Testing
- Use a credential offer from issuer.multipaz.org for any document
- Multipaz getting started sample app will get triggered
- The in-app provisioning screen will:
- Launch the browser for OAuth when needed.
- Upon selecting the appropriate user id (in our case for testing), you’ll get redirected again to the app again
- Wait for the app-link callback and continue.
- Display progress (Connected, Authorized, Requesting credentials, etc.).
- Launch the browser for OAuth when needed.
- After issuance, your credential appears in the app’s DocumentStore and is ready for presentment.
- You can see the new doc in the list of documents in the UI on the next app
Production Notes
- Keys and secrets
- Do not embed private keys in the client. Implement
OpenID4VCIBackendon your server. - Replace
CLIENT_IDand redirect URL with your own values. - Generate your own keys and attestation materials.
- Do not embed private keys in the client. Implement
- Use App links
- Host a Digital Asset Links file at
https://<your-domain>/.well-known/assetlinks.jsoncontaining your Android package name and signing cert SHA-256. - Add an Android
VIEWintent filter withandroid:autoVerify="true"for your HTTPS domain and path. - Example
assetlinks.jsonfile:
- Host a Digital Asset Links file at
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "org.multipaz.getstarted",
"sha256_cert_fingerprints": [
"AA:BB:CC:...:ZZ" // replace with your app's signing cert SHA-256
]
}
}
]
- Get the SHA-256 fingerprint using
keytool -list -v -keystore <path-to-keystore>
- Make sure your redirect URL matches your manifest filter, e.g.
https://getstarted.multipaz.org/landing/…consistently across:- Issuer configuration
- App’s
ProvisioningSupport.APP_LINK_BASE_URL - Manifest HTTPS intent filter
By following these steps, you’ve added OpenID4VCI-based credential issuance to the Multipaz Getting Started Sample, including URL handling, a minimal back-end for testing, and a simple authorization UI.