This cookbook walks through the full OID4VCI 1.0 FINAL holder-side flow for an OEM native mobile app embedding the CodeB Web Wallet backend. Both grants are covered: pre-authorized code (from a merchant QR / deep-link) and authorization code + PAR (issuer-initiated with user consent in a browser). Both formats are covered: dc+sd-jwt (SD-JWT VC) and mso_mdoc (ISO 18013-5 mDoc). Wallet private keys are minted on-device in the platform's secure hardware store — Secure Enclave on iOS, Android Keystore StrongBox where available. The European Digital Identity Wallet ecosystem is the interop target throughout.
Contract of record. The endpoint contract this cookbook builds against is wallet-api.html. If anything here disagrees with the reference, the reference wins — open an issue.
1 Parse the credential offer
Offers arrive as deep-links your OEM app registers to handle: openid-credential-offer://, haip-vci://, or a bespoke URL scheme. Two shapes:
func handleIncomingURL(_ url: URL) throws -> CredentialOffer {
guard url.scheme == "openid-credential-offer" || url.scheme == "haip-vci" else {
throw WalletError.unsupportedScheme
}
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false)!
if let inline = comps.queryItems?.first(where: { $0.name == "credential_offer" })?.value {
let data = inline.removingPercentEncoding!.data(using: .utf8)!
return try JSONDecoder().decode(CredentialOffer.self, from: data)
}
if let uri = comps.queryItems?.first(where: { $0.name == "credential_offer_uri" })?.value {
let (data, _) = try await URLSession.shared.data(from: URL(string: uri)!)
return try JSONDecoder().decode(CredentialOffer.self, from: data)
}
throw WalletError.emptyOffer
}
Android Kotlin
suspend fun handleIncomingUri(uri: Uri): CredentialOffer {
require(uri.scheme in setOf("openid-credential-offer", "haip-vci")) {
"unsupported scheme: ${uri.scheme}"
}
uri.getQueryParameter("credential_offer")?.let {
return json.decodeFromString(URLDecoder.decode(it, "UTF-8"))
}
uri.getQueryParameter("credential_offer_uri")?.let { ref ->
val body = withContext(Dispatchers.IO) { URL(ref).readText() }
return json.decodeFromString(body)
}
throw IllegalArgumentException("offer missing both credential_offer and credential_offer_uri")
}
2 Discover metadata (A1 + A2)
Two GETs, both public and unauthenticated:
GET <credential_issuer>/.well-known/openid-credential-issuer → credential_endpoint, nonce_endpoint, credential_configurations_supported, authorization_servers.
GET <as>/.well-known/oauth-authorization-server → RFC 8414 metadata: authorization_endpoint, token_endpoint, pushed_authorization_request_endpoint, registration_endpoint.
Cache aggressively (Cache-Control from the server; typically 1 h). Fall back to /.well-known/openid-configuration if the OAuth-AS variant returns 404 (some issuers ship only the OIDC discovery doc).
suspend fun fetchMetadata(issuer: String): IssuerMetadata = withContext(Dispatchers.IO) {
val url = "$issuer/.well-known/openid-credential-issuer"
json.decodeFromString(URL(url).readText())
}
3 Obtain the access token (A5)
Pre-authorized-code grant. The offer already contains the code; if the issuer requires a tx_code (typical 4–6 digit PIN out-of-band), collect it from the user and include it. POST x-www-form-urlencoded:
Response: { "access_token": "ey...", "token_type": "Bearer", "expires_in": 300, "c_nonce": "..." }. The initial c_nonce is here (if not, A6 mints one).
Authorization-code grant. Bigger: PAR (A4) then a browser hop then /token with PKCE verifier. Use ASWebAuthenticationSession on iOS or Chrome Custom Tabs on Android for the browser part.
iOS Swift — pre-auth
func tokenPreAuth(_ md: IssuerMetadata, code: String, txCode: String?) async throws -> TokenResponse {
var form = "grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code"
form += "&pre-authorized_code=\(code.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!)"
if let tx = txCode { form += "&tx_code=\(tx)" }
var req = URLRequest(url: URL(string: md.token_endpoint)!)
req.httpMethod = "POST"
req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
req.httpBody = form.data(using: .utf8)
let (data, _) = try await URLSession.shared.data(for: req)
return try JSONDecoder().decode(TokenResponse.self, from: data)
}
iOS Swift — authorization-code + PAR + PKCE
// Generate PKCE + state
let verifier = randomURLSafe(length: 64)
let challenge = SHA256.hash(data: verifier.data(using: .utf8)!).b64url()
let state = randomURLSafe(length: 24)
// 1. Push authorization request (PAR)
let par = try await parRequest(md, verifier: verifier, challenge: challenge, state: state)
// 2. Browser hop
let authURL = URL(string: "\(md.authorization_endpoint)?client_id=\(clientId)&request_uri=\(par.request_uri)")!
let session = ASWebAuthenticationSession(url: authURL, callbackURLScheme: "cb-oidc") { callback, err in
// callback URL carries ?code=&state=
}
session.presentationContextProvider = self
session.start()
// 3. Exchange code for token
let code = extractCode(from: callback)
let token = try await tokenAuthCode(md, code: code, verifier: verifier)
Android Kotlin — pre-auth
suspend fun tokenPreAuth(md: IssuerMetadata, code: String, txCode: String?): TokenResponse {
val form = buildString {
append("grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code")
append("&pre-authorized_code=").append(URLEncoder.encode(code, "UTF-8"))
txCode?.let { append("&tx_code=").append(it) }
}
return withContext(Dispatchers.IO) {
val conn = (URL(md.token_endpoint).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
doOutput = true
setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
outputStream.use { it.write(form.toByteArray()) }
}
json.decodeFromString(conn.inputStream.bufferedReader().readText())
}
}
4 Generate the wallet keypair (Secure Enclave / Keystore)
Every credential MUST be bound to a fresh keypair minted on-device in the platform's secure hardware store. The private key never leaves the enclave; only signatures do.
iOS Swift
import CryptoKit
import Security
func createWalletKey(credentialId: String) throws -> SecureEnclave.P256.Signing.PrivateKey {
// Secure Enclave path -- available on all modern iOS devices with the T2/A-series enclave.
let accessControl = SecAccessControlCreateWithFlags(
nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet], nil)!
let privateKey = try SecureEnclave.P256.Signing.PrivateKey(
accessControl: accessControl)
// Persist the raw representation under the credential id -- iOS keeps the
// enclave-bound key material safe; representationData is just the handle.
try KeychainStore.save(privateKey.dataRepresentation, tag: "wallet-key-\(credentialId)")
return privateKey
}
func publicJwk(_ key: SecureEnclave.P256.Signing.PrivateKey) -> [String: String] {
let raw = key.publicKey.rawRepresentation // 64 bytes: X (32) || Y (32)
return [
"kty": "EC", "crv": "P-256",
"x": raw.prefix(32).b64url(),
"y": raw.suffix(32).b64url()
]
}
Android Kotlin
import java.security.KeyPairGenerator
import java.security.KeyStore
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
fun createWalletKey(credentialId: String): KeyPair {
val alias = "wallet-key-$credentialId"
val spec = KeyGenParameterSpec.Builder(alias,
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY)
.setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
.setDigests(KeyProperties.DIGEST_SHA256)
.setUserAuthenticationRequired(false) // set true + biometry gate for higher assurance
.setIsStrongBoxBacked(true) // best-effort; falls back to TEE
.build()
val gen = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore")
gen.initialize(spec)
return gen.generateKeyPair()
}
fun publicJwk(pub: java.security.interfaces.ECPublicKey): Map<String, String> {
val point = pub.w
// BigInteger.toByteArray() is a signed two's-complement encoding:
// - a 32-byte unsigned coordinate whose high bit is 1 comes back as 33
// bytes (a leading 0x00 sign byte is prepended)
// - a coordinate with several leading-zero bytes comes back short
// JOSE JWK requires exactly 32 bytes per coordinate. padTo32 must strip
// a leading sign byte if length == 33, or left-pad with zeros if shorter.
return mapOf("kty" to "EC", "crv" to "P-256",
"x" to point.affineX.toUnsignedFixed(32).b64url(),
"y" to point.affineY.toUnsignedFixed(32).b64url())
}
// Correct helper -- both leading-sign-byte + short-encoding cases handled.
fun BigInteger.toUnsignedFixed(size: Int): ByteArray {
val raw = this.toByteArray()
return when {
raw.size == size -> raw
raw.size == size + 1 && raw[0] == 0.toByte() -> raw.copyOfRange(1, raw.size)
raw.size < size -> ByteArray(size - raw.size) + raw
else -> error("coordinate does not fit in $size bytes")
}
}
Signed with the wallet key from step 4. Use the c_nonce from the token response, or fetch a fresh one from the issuer's nonce_endpoint (A6) if the current one is stale.
iOS Swift
func buildProofJwt(clientId: String, issuer: String, nonce: String,
key: SecureEnclave.P256.Signing.PrivateKey) throws -> String {
let header: [String: Any] = [
"typ": "openid4vci-proof+jwt",
"alg": "ES256",
"jwk": publicJwk(key)
]
let payload: [String: Any] = [
"iss": clientId, "aud": issuer,
"iat": Int(Date().timeIntervalSince1970), "nonce": nonce
]
let h64 = try JSONSerialization.data(withJSONObject: header).b64url()
let p64 = try JSONSerialization.data(withJSONObject: payload).b64url()
let signingInput = "\(h64).\(p64)"
let sig = try key.signature(for: signingInput.data(using: .utf8)!)
// CryptoKit's SecureEnclave signatures expose `.rawRepresentation` as the
// 64-byte raw R || S concatenation that JOSE expects (32 bytes per
// coordinate). No DER conversion needed. If you're on the software P-256
// API (P256.Signing.PrivateKey), same guarantee -- `.rawRepresentation` is
// 64 raw bytes; `.derRepresentation` is the ASN.1 form.
return "\(signingInput).\(sig.rawRepresentation.b64url())"
}
Android Kotlin
fun buildProofJwt(clientId: String, issuer: String, nonce: String,
pair: KeyPair, publicJwk: Map<String, String>): String {
val header = json.encodeToString(mapOf(
"typ" to "openid4vci-proof+jwt", "alg" to "ES256",
"jwk" to JsonObject(publicJwk.mapValues { JsonPrimitive(it.value) })
))
val payload = json.encodeToString(mapOf(
"iss" to clientId, "aud" to issuer,
"iat" to (System.currentTimeMillis() / 1000L),
"nonce" to nonce
))
val h64 = header.toByteArray().b64url()
val p64 = payload.toByteArray().b64url()
val signingInput = "$h64.$p64"
val sig = Signature.getInstance("SHA256withECDSA").apply {
initSign(pair.private)
update(signingInput.toByteArray())
}.sign()
// ASN.1 DER -> raw R||S for JOSE compact.
val raw = derToRaw(sig, coordinateBytes = 32)
return "$signingInput.${raw.b64url()}"
}
6 POST /credential (A7 SD-JWT / A8 mDoc)
Header: Authorization: Bearer <access_token>. Body varies by format.
Response: { "credential": "<base64url CBOR IssuerSigned>", "format": "mso_mdoc" }. Store the base64url string as-is; decode only when presenting.
If the server returns { "error": "invalid_proof", "c_nonce": "..." }, the c_nonce has rotated. Rebuild the proof-JWT with the new nonce and retry.
7 Persist the credential + key handle
Store the credential in your app's secure store, keyed by a UUID you generate locally. Alongside it, persist a reference to the wallet private-key handle so you can find it again when presenting.
iOS Swift
struct StoredCredential: Codable {
let id: UUID // your local id
let format: String // "dc+sd-jwt" or "mso_mdoc"
let vctOrDoctype: String
let credential: String // SD-JWT string or base64url CBOR
let keychainTag: String // "wallet-key-<id>"
let importedAt: Date
}
// Persist to Core Data with SQLCipher, or to Keychain if small enough.
try CredentialStore.save(StoredCredential(...))
Android Kotlin
@Entity
data class StoredCredential(
@PrimaryKey val id: String,
val format: String,
val vctOrDoctype: String,
val credential: String,
val keystoreAlias: String,
val importedAt: Long
)
// Room + SQLCipher for encryption at rest.
db.credentialDao().insert(sc)