OEM Cookbook · native credential presentation · OID4VP holder

Present a credential from your native app.

OID4VP 1.0 FINAL holder-side flow. Your OEM app receives a presentation request (deep-link), resolves the verifier's DCQL query against locally held credentials, builds a vp_token (SD-JWT with selective disclosures + KB-JWT for key binding, or mDoc DeviceResponse), and posts it back. Two response modes: direct_post (plain form) and direct_post.jwt (JWE-encrypted, ECDH-ES + A128GCM). Optional HAIP §5.11 Wallet Attestation as OAuth-Client-Attestation header when the verifier requires it. Interop target: European Digital Identity Wallet ecosystem.

The C1 jar-proxy detour is browser-only. Native apps have no CORS and MUST fetch request_uri directly. Do not route via /oidc.ashx?action=jar-proxy.

1 Parse the OID4VP request

Deep-link scheme: openid4vp://, haip-vp://, or a bespoke URL scheme your app claims. Two shapes: inline (all params in query string) or by reference (request_uri). By-reference dominates in practice because request objects contain long JWKs.

openid4vp://authorize?client_id=x509_san_dns:verifier.example&request_uri=https://verifier.example/req/xyz

iOS Swift

func handlePresentationURL(_ url: URL) throws -> RequestReference {
    guard ["openid4vp", "haip-vp"].contains(url.scheme) else { throw WalletError.unsupportedScheme }
    let comps = URLComponents(url: url, resolvingAgainstBaseURL: false)!
    let clientId = comps.queryItems?.first { $0.name == "client_id" }?.value ?? ""
    let requestUri = comps.queryItems?.first { $0.name == "request_uri" }?.value
    return RequestReference(clientId: clientId, requestUri: requestUri)
}

2 Fetch the JAR directly

GET request_uri with Accept: application/oauth-authz-req+jwt. Response is a signed JWT (JWS Compact). Verify the signature using the x5c chain in the header (Access Certificate per ARF 3.0) or the JWKS the verifier metadata advertises.

Header includes typ=oauth-authz-req+jwt per HAIP. Payload is the OID4VP request object: client_id, response_type=vp_token, response_mode, response_uri, nonce, state, dcql_query, and (for JWE mode) client_metadata.jwks.

iOS Swift

func fetchJar(uri: URL) async throws -> RequestObject {
    var req = URLRequest(url: uri)
    req.setValue("application/oauth-authz-req+jwt", forHTTPHeaderField: "Accept")
    let (data, _) = try await URLSession.shared.data(for: req)
    let jwt = String(data: data, encoding: .utf8)!
    return try verifyAndParseJwt(jwt)   // checks x5c chain against your trust anchors
}

Android Kotlin

suspend fun fetchJar(uri: String): RequestObject = withContext(Dispatchers.IO) {
    val conn = (URL(uri).openConnection() as HttpURLConnection).apply {
        setRequestProperty("Accept", "application/oauth-authz-req+jwt")
    }
    val jwt = conn.inputStream.bufferedReader().readText()
    verifyAndParseJwt(jwt)  // x5c chain -> your trust anchors
}

3 Resolve DCQL against local credentials

The request's dcql_query shape (OID4VP 1.0 FINAL sec 7.2):

{
  "credentials": [
    { "id": "pid_sdjwt", "format": "dc+sd-jwt",
      "meta": { "vct_values": ["urn:eudi:pid:1"] },
      "claims": [
        { "path": ["given_name"] },
        { "path": ["family_name"] }
      ]
    }
  ],
  "credential_sets": [
    { "purpose": "Sign in", "options": [["pid_sdjwt"]], "required": true }
  ]
}

Iterate credentials[]: for each entry, find local credentials whose vct (or doctype) matches meta.vct_values / meta.doctype_value. Confirm your credential contains all the requested claims[].path values. If multiple candidates exist, prompt the user to pick.

iOS Swift

func resolveDcql(_ query: DcqlQuery, store: CredentialStore) -> [DcqlMatch] {
    query.credentials.compactMap { credSpec in
        let candidates = store.all.filter { c in
            switch credSpec.format {
            case "dc+sd-jwt":
                return credSpec.meta.vct_values?.contains(c.vctOrDoctype) ?? false
            case "mso_mdoc":
                return credSpec.meta.doctype_value == c.vctOrDoctype
            default: return false
            }
        }
        return candidates.first.map { DcqlMatch(spec: credSpec, credential: $0) }
    }
}

4 Build the vp_token

SD-JWT VC branch

Emit the SD-JWT + only the disclosures corresponding to claims[].path (selective disclosure). Then append a KB-JWT (Key Binding JWT) signed by the wallet key that cnf.jwk in the SD-JWT payload identifies.

KB-JWT header: { "typ": "kb+jwt", "alg": "ES256" }. KB-JWT payload:

{
  "iat": <now>,
  "aud": "<client_id from JAR>",
  "nonce": "<nonce from JAR>",
  "sd_hash": "<base64url(sha256(sd-jwt || '~' || disclosure1 || '~' || ... || '~'))>"
}

mDoc branch

Build a CBOR DeviceResponse whose documents[].deviceSigned.deviceSignature is a COSE_Sign1 over DeviceAuthentication = ["DeviceAuthentication", SessionTranscript, docType, DeviceNameSpacesBytes]. SessionTranscript for OID4VP per OID4VP 1.0 FINAL §B.2.6.1:

SessionTranscript = [ null, null, OID4VPHandover ]

OID4VPHandover = [ clientIdHash, responseUriHash, mdocGeneratedNonce ]

  clientIdHash    = SHA-256( CBOR-encode( [ clientId,    mdoc_generated_nonce ] ) )
  responseUriHash = SHA-256( CBOR-encode( [ responseUri, mdoc_generated_nonce ] ) )

Both hashes are computed over the CBOR encoding of a two-element array [value, mdoc_generated_nonce]. The third element of the handover is the raw mdoc_generated_nonce (NOT the request's nonce). Getting either wrong causes the verifier to reject the deviceSignature.

The mdoc_generated_nonce transport back to the verifier depends on response mode. direct_post: form field. direct_post.jwt: JWE apu header (base64url, max 256 chars). Per OID4VP 1.0 FINAL §8.1.

iOS Swift — SD-JWT KB-JWT

func buildKbJwt(sdJwt: String, disclosures: [String], key: WalletKey,
                clientId: String, nonce: String) throws -> String {
    // sd_hash = sha256(sd-jwt~disclosure1~disclosure2~...~)
    let composite = ([sdJwt] + disclosures + [""]).joined(separator: "~")
    let sdHash = SHA256.hash(data: composite.data(using: .utf8)!).b64url()
    let header = ["typ": "kb+jwt", "alg": "ES256"]
    let payload: [String: Any] = [
        "iat": Int(Date().timeIntervalSince1970),
        "aud": clientId, "nonce": nonce, "sd_hash": sdHash
    ]
    return try signJwt(header: header, payload: payload, key: key)
}

func vpToken(sdJwt: String, disclosures: [String], kbJwt: String) -> String {
    return ([sdJwt] + disclosures + [kbJwt]).joined(separator: "~")
}

5 (When response_mode = direct_post.jwt) encrypt the response

Per HAIP §4, wallet responses to LOTL-attested verifiers MUST be encrypted. JWE algorithm: alg=ECDH-ES, enc=A128GCM. The verifier's ephemeral public key is in client_metadata.jwks in the JAR payload.

For mDoc: put mdoc_generated_nonce as base64url in the JWE apu header (max 256 chars).

iOS Swift

func encryptResponse(plaintext: Data, verifierEpk: P256.KeyAgreement.PublicKey,
                     mdocNonce: String?) throws -> String {
    // 1. Generate our ephemeral keypair
    let ourEphemeral = P256.KeyAgreement.PrivateKey()
    // 2. ECDH
    let shared = try ourEphemeral.sharedSecretFromKeyAgreement(with: verifierEpk)
    // 3. Concat KDF (NIST SP 800-56A) -> CEK
    let cek = shared.hkdfDerivedSymmetricKey(
        using: SHA256.self, salt: Data(),
        sharedInfo: concatKdfInfo(alg: "ECDH-ES", enc: "A128GCM", apu: mdocNonce),
        outputByteCount: 16)
    // 4. Header
    var header: [String: Any] = [
        "alg": "ECDH-ES", "enc": "A128GCM",
        "epk": jwkFromPublicKey(ourEphemeral.publicKey)
    ]
    if let nonce = mdocNonce { header["apu"] = nonce.b64urlEncoded() }
    // 5. AES-128-GCM
    let iv = Data(randomBytes: 12)
    let sealed = try AES.GCM.seal(plaintext, using: cek, nonce: AES.GCM.Nonce(data: iv))
    // 6. Compact JWE
    return jweCompact(header, encryptedKey: "", iv: iv, ciphertext: sealed.ciphertext, tag: sealed.tag)
}

6 POST the response (C2)

URL: the response_uri from the JAR. Content-Type: application/x-www-form-urlencoded.

direct_post body:

vp_token=<sd-jwt~disclosures~kb-jwt OR base64url(mdoc)>
state=<from JAR>
mdoc_generated_nonce=<only for mDoc, only when direct_post>

direct_post.jwt body:

response=<JWE compact string>
mdoc_generated_nonce=<only for mDoc; alternative to JWE apu header>

Response from the verifier (200): { "redirect_uri": "https://verifier.example/return?state=..." }. Your app can follow that URL to complete the verifier's post-presentation flow.

7 (When verifier requires it) attach Wallet Attestation

Some verifiers demand HAIP §5.11 attestation of the wallet's identity + platform integrity. When set, attach as header:

OAuth-Client-Attestation: <WA JWT from C3>

See the Wallet Attestation onboarding cookbook for how your OEM app gets a WA issued (KYB + platform attestation).

Next: Wallet Attestation → Previous: import API reference