OEM Cookbook · native credential import · OID4VCI holder

Import a credential into your native app.

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:

  • Inline: ?credential_offer=<url-encoded JSON> — parse directly.
  • By reference: ?credential_offer_uri=https://issuer/.../offer/xyz — GET that URL to fetch the offer JSON (endpoint A9 in the reference).

Inline offer body:

{
  "credential_issuer": "https://issuer.example",
  "credential_configuration_ids": ["urn:eudi:pid:1"],
  "grants": {
    "urn:ietf:params:oauth:grant-type:pre-authorized_code": {
      "pre-authorized_code": "abc123",
      "tx_code": { "input_mode": "numeric", "length": 4 }
    }
  }
}

iOS Swift

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

iOS Swift

func fetchMetadata(issuer: URL) async throws -> IssuerMetadata {
    let mdURL = issuer.appendingPathComponent(".well-known/openid-credential-issuer")
    let (data, _) = try await URLSession.shared.data(from: mdURL)
    return try JSONDecoder().decode(IssuerMetadata.self, from: data)
}

Android Kotlin

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:

grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code
pre-authorized_code=abc123
tx_code=4711

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

5 Build the openid4vci-proof+jwt

Header:

{ "typ": "openid4vci-proof+jwt", "alg": "ES256", "jwk": { "kty":"EC","crv":"P-256","x":"...","y":"..." } }

Payload:

{ "iss": "<client_id>", "aud": "<credential_issuer>", "iat": 1700000000, "nonce": "<c_nonce>" }

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.

SD-JWT VC

{
  "format": "dc+sd-jwt",
  "vct":    "urn:eudi:pid:1",
  "proof":  { "proof_type": "jwt", "jwt": "<openid4vci-proof+jwt>" }
}

Response: { "credential": "<sd-jwt>", "format": "dc+sd-jwt" }.

mDoc (mso_mdoc)

{
  "format":  "mso_mdoc",
  "doctype": "eu.europa.ec.eudi.pid.1",
  "proof":   { "proof_type": "jwt", "jwt": "<openid4vci-proof+jwt>" }
}

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)

? Common error cases

  • 401 invalid_token on /credential — access token expired. Re-run step 3.
  • 400 invalid_proof — check typ=openid4vci-proof+jwt, alg=ES256, embedded jwk matches the key that signed. Also confirm aud=credential_issuer.
  • 400 invalid_nonce — c_nonce rotated. The response body includes the fresh one; retry.
  • 400 unsupported_credential_format — issuer does not offer this vct/doctype. Re-check A1 metadata.
  • DER-to-raw signature conversion — both platforms return DER-encoded ECDSA signatures. JOSE requires raw R || S. Always convert.

Next: Present credentials → API reference OEM landing