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 and initialize ProvisioningModel.
// ...
class App {
// ...
lateinit var provisioningModel: ProvisioningModel
val 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 = org.multipaz.util.Platform.getSecureArea(),
httpClient = io.ktor.client.HttpClient() {
followRedirects = false
},
promptModel = org.multipaz.util.Platform.promptModel,
documentMetadataInitializer = ::initializeDocumentMetadata
)
}
}
}
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
val stableProvisioningModel = remember(provisioningModel) { provisioningModel }
val stableProvisioningSupport = remember(provisioningSupport) { provisioningSupport }
// Listen for credential offers and launch OID4VCI flow
LaunchedEffect(true) {
while (true) {
val credentialOffer = credentialOffers.receive()
stableProvisioningModel.launchOpenID4VCIProvisioning(
offerUri = credentialOffer,
clientPreferences = ProvisioningSupport.OPENID4VCI_CLIENT_PREFERENCES,
backend = stableProvisioningSupport
)
isProvisioning = true
}
}
Column(/* ... */) {
if (isProvisioning) {
ProvisioningTestScreen(
stableProvisioningModel,
stableProvisioningSupport,
)
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")
}
}
}
}
}
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
)
}
}
Refer to this metadata init function code for the complete example.
Demo “wallet back-end” (OpenID4VCIBackend)
The sample includes ProvisioningSupport, an in-app implementation of OpenID4VCIBackend to sign:
- Client assertions (for token exchange)
- Wallet attestation JWT
- Key attestation JWT
It 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:
- Implements:
createJwtClientAssertion(tokenUrl: String)createJwtWalletAttestation(keyAttestation: KeyAttestation)createJwtKeyAttestation(keyAttestations: List<KeyAttestation>, challenge: String)
- Manages app-link OAuth callbacks using a state-channel:
waitForAppLinkInvocation(state)processAppLinkInvocation(url)
class ProvisioningSupport : OpenID4VCIBackend {
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/"
// Client identity and client preferences used during OID4VCI.
const val CLIENT_ID = "urn:uuid:418745b8-78a3-4810-88df-7898aff3ffb4"
val OPENID4VCI_CLIENT_PREFERENCES = OpenID4VCIClientPreferences(
clientId = CLIENT_ID,
redirectUrl = APP_LINK_BASE_URL,
locales = listOf("en-US"),
signingAlgorithms = listOf(Algorithm.ESP256, Algorithm.ESP384, Algorithm.ESP512)
)
}
// Wait for wallet redirect: state is provided by the issuer during OAuth
private val lock = Mutex()
private val pendingLinksByState = mutableMapOf<String, SendChannel<String>>()
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()
}
// Sign a JWT client assertion for token endpoint
override suspend fun createJwtClientAssertion(tokenUrl: 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
): String { /* signs with attestation key */ }
}
You can copy-paste the ProvisioningSupport 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 keys from Compose resources (do not ship these in production; move to a backend):
- JWK for local client assertion signing from
files/provisioning_local_assertion_jwk.json - Attestation certificate from
files/provisioning_attestation_certificate.pem - Attestation private key from
files/provisioning_attestation_private_key.pem
You can download these files from here. Place these under: composeApp/src/commonMain/composeResources/files/
These are cached in-memory and used to produce compact JWTs with COSE-encoded signatures.
Provisioning UI
ProvisioningTestScreen consumes ProvisioningModel.state and renders:
- OAuth authorization challenge: opens browser to issuer, waits for app redirect, returns the invoked redirect URL to the model
- Secret text challenge: displays a passphrase field with constraints
- Progress and error states
@Composable
fun ProvisioningTestScreen(
provisioningModel: ProvisioningModel,
provisioningSupport: ProvisioningSupport,
) {
val state = provisioningModel.state.collectAsState(ProvisioningModel.Idle).value
Column {
when (state) {
is ProvisioningModel.Authorizing -> Authorize(
provisioningModel,
state.authorizationChallenges,
provisioningSupport
)
is ProvisioningModel.Error -> {
Text(
modifier = Modifier.align(Alignment.CenterHorizontally).padding(8.dp),
style = MaterialTheme.typography.titleLarge,
text = "Error: ${state.err.message}"
)
Text(
modifier = Modifier.padding(4.dp),
style = MaterialTheme.typography.bodyMedium,
text = "For details: adb logcat -s ProvisioningModel"
)
}
else -> {
val label = when (state) {
ProvisioningModel.Idle -> "Initializing..."
ProvisioningModel.Initial -> "Starting provisioning..."
ProvisioningModel.Connected -> "Connected to the back-end"
ProvisioningModel.ProcessingAuthorization -> "Processing authorization..."
ProvisioningModel.Authorized -> "Authorized"
ProvisioningModel.RequestingCredentials -> "Requesting credentials..."
ProvisioningModel.CredentialsIssued -> "Credentials issued"
else -> ""
}
Text(
modifier = Modifier.align(Alignment.CenterHorizontally).padding(8.dp),
style = MaterialTheme.typography.titleLarge,
text = label
)
}
}
}
}
@Composable
private fun Authorize(
provisioningModel: ProvisioningModel,
challenges: List<AuthorizationChallenge>,
provisioningSupport: ProvisioningSupport
) {
when (val challenge = challenges.first()) {
is AuthorizationChallenge.OAuth -> EvidenceRequestWebView(challenge, provisioningModel, provisioningSupport)
is AuthorizationChallenge.SecretText -> EvidenceRequestSecretText(challenge, provisioningModel)
}
}
@Composable
fun EvidenceRequestWebView(
evidenceRequest: AuthorizationChallenge.OAuth,
provisioningModel: ProvisioningModel,
provisioningSupport: ProvisioningSupport
) {
// Wait for the redirect invocation (get-started-app://landing/?state=...)
LaunchedEffect(evidenceRequest.url) {
val invokedUrl = provisioningSupport.waitForAppLinkInvocation(evidenceRequest.state)
provisioningModel.provideAuthorizationResponse(
AuthorizationResponse.OAuth(evidenceRequest.id, invokedUrl)
)
}
// Launch external browser
LaunchedEffect(evidenceRequest.url) {
LocalUriHandler.current.openUri(evidenceRequest.url)
}
// Simple hint text...
}
You can copy-paste the ProvisioningTestScreen file for the complete implementation.
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.