France Identité OID4VCI Android Integration¶
Table of Contents¶
- Overview
- Architecture Overview
- Cryptographic Material Reference
- Key Inventory
- Nonce Reference
- Issuer Metadata
- Base URL and Environments
- Flow Overview
- Sequence Diagram
- Step Summary
- Step 1 — Deeplink Invocation
- Step 2 — Credential Manager Request
- Step 3 — Challenge Request
- Step 4 — Token Request
- Step 5 — Nonce Request
- Step 6 — Credential Request
- Error Handling
- Security Considerations
- Android Integration Notes
- Out of Scope
- Playground Trace Examples
- Playground Certificates
- References
Overview¶
This document is addressed to teams implementing an Android wallet that integrates with the France Identité app to receive an age verification credential using the OpenID for Verifiable Credential Issuance (OpenID4VCI) 1.0 protocol and Android Credential Manager.
The calling wallet plays two consecutive roles in this flow:
- Initiator — it launches the France Identité app via a deeplink, which triggers authentication and credential offer retrieval on the France Identité side.
- Credential Manager provider — it is registered as a Digital Credential provider in Android Credential Manager. Once France Identité passes the credential request to Credential Manager and the user selects this wallet in the chooser, the wallet receives the request and performs the OID4VCI network exchanges.
The wallet performs four sequential network exchanges with the issuer: Challenge, Token, Nonce, and Credential. The Token and Credential exchanges use DPoP (Demonstrating Proof of Possession, RFC 9449). The Token exchange additionally requires a Client Attestation proving the wallet's identity.
Architecture Overview¶
Actors¶
| Calling Wallet Application | Calling Wallet Backend | Android Credential Manager | France Identité App | France Identité Backend (Credential Issuer) | |
|---|---|---|---|---|---|
| Description | The Android wallet application implementing this guide. Plays two roles: initiates the flow via deeplink, then receives the credential request as a registered Credential Manager provider and performs the OID4VCI exchanges. | The wallet's own backend, responsible for generating and signing the OAuth-Client-Attestation JWT. |
Android system component that mediates the handoff of the credential request from France Identité to the wallet. | Authenticates the user, retrieves the credential offer, and triggers Credential Manager. | Issues OAuth2 access tokens, validates proofs, and generates signed credentials. |
Cryptographic Material Reference¶
A single issuance session involves multiple key pairs and nonces. This section maps each piece of cryptographic material to where it originates and where it is used, to prevent confusion before reading the step-by-step detail.
Key Inventory¶
| Key | Algorithm | Lifetime | Storage | Purpose |
|---|---|---|---|---|
| Ephemeral Session Key | EC P-256 | Per issuance session | In memory only | Signs DPoP JWTs (token and credential requests); embedded in Client Attestation cnf.jwk; signs the Client Attestation PoP |
| Credential Binding Key | EC P-256 / COSE_Key | Long-lived (per credential) | Android Keystore (hardware-backed) | Bound to the issued credential as the device key in the MSO; used as the signing key in the jwt proof type and the android_keystore_attestation proof type |
| Credential Response Encryption Key | EC P-256 | Per issuance session (ephemeral) | In memory only; discard after decrypting the response | Wallet provides the public half in the credential request so the issuer can encrypt the response; private half decrypts it |
| Issuer Credential Request Encryption Key | EC P-256 | Long-lived (issuer-managed) | Embedded in signed issuer metadata at credential_request_encryption.jwks |
Used by the wallet to encrypt the credential request body |
The Ephemeral Session Key and the Credential Binding Key are distinct key pairs and must never be the same key. The Ephemeral Session Key is represented as a JWK in DPoP and Client Attestation material, then discarded after the session. The Credential Binding Key persists and is bound to the mdoc credential as a COSE_Key. For the
attestationandandroid_keystore_attestationproof types, the attested key is the Credential Binding Key, not the Ephemeral Session Key.
Nonce Reference¶
Four distinct nonce values flow through a session. Each originates from a different response and is consumed in a different place.
| Nonce | Type | Origin | Delivered via | Consumed in | Consumed as |
|---|---|---|---|---|---|
attestation_challenge |
Server-generated | Challenge response | Response body | Client Attestation PoP JWT | challenge claim |
DPoP-Nonce (1st) |
Server-generated | Challenge response | DPoP-Nonce response header or equivalent response metadata |
Token request DPoP JWT | nonce claim |
c_nonce |
Server-generated | Nonce response | Response body | Proof JWT (inside credential request body) | nonce claim |
DPoP-Nonce (2nd) |
Server-generated | Nonce response | DPoP-Nonce response header or equivalent response metadata |
Credential request DPoP JWT | nonce claim |
attestation_challengeand the first DPoP nonce are returned for the same stage but serve entirely different purposes. Do not derive one from the other or reuse one in place of the other.
Issuer Metadata¶
Before initiating any exchange, the wallet retrieves the issuer's metadata from the well-known discovery endpoint. This document contains the URLs for all subsequent requests and the embedded JWKS needed to encrypt the credential request.
Discovery Endpoint URL Construction¶
In OID4VCI 1.0, when the issuer identifier contains a path component, the /.well-known/openid-credential-issuer segment is inserted between the host and the path, not appended at the end.
Request¶
Response Format¶
The server returns a signed JWT (Content-Type: application/jwt). The wallet MUST verify the JWT signature and claims before trusting the payload.
The JWT payload contains the metadata claims. Example decoded payload:
{
"iss": "{credential_issuer}",
"sub": "{credential_issuer}",
"iat": <unix_timestamp>,
"credential_issuer": "{credential_issuer}",
"credential_endpoint": "{credential_issuer}/credential",
"token_endpoint": "{credential_issuer}/token",
"challenge_endpoint": "{credential_issuer}/challenge",
"nonce_endpoint": "{credential_issuer}/nonce",
"credential_request_encryption": {
"encryption_required": true,
"jwks": {
"keys": [
{
"kty": "EC",
"crv": "P-256",
"alg": "ECDH-ES",
"use": "enc",
"kid": "<kid>",
"x": "<x_coordinate>",
"y": "<y_coordinate>"
}
]
},
"enc_values_supported": ["A256GCM"]
},
"credential_response_encryption": {
"encryption_required": false,
"alg_values_supported": ["ECDH-ES"],
"enc_values_supported": ["A256GCM"]
},
"credential_configurations_supported": {
"eu.europa.ec.av.1": {
"format": "mso_mdoc",
"doctype": "eu.europa.ec.av.1",
"cryptographic_binding_methods_supported": ["cose_key"],
"credential_signing_alg_values_supported": [-9],
"proof_types_supported": {
"android_keystore_attestation": {
"attestations_required": {},
"x509_signature_algorithm_oids_supported": [
"1.2.840.10045.4.3.2",
"1.2.840.113549.1.1.11"
]
},
"attestation": {
"key_attestations_required": {
"key_storage": [
"iso_18045_high",
"iso_18045_moderate",
"iso_18045_enhanced",
"iso_18045_basic"
]
},
"proof_signing_alg_values_supported": [
"ES256"
]
},
"jwt": {
"key_attestations_required": {},
"proof_signing_alg_values_supported": [
"ES256"
]
}
}
}
}
}
Metadata Notes¶
x509_signature_algorithm_oids_supported. The two OIDs in theandroid_keystore_attestationproof type describe which signature algorithms are accepted in the attestation certificate chain:1.2.840.10045.4.3.2isecdsa-with-SHA256,1.2.840.113549.1.1.11issha256WithRSAEncryption.
Metadata JWT Validation¶
Before using the decoded metadata, the wallet MUST:
- Verify the metadata JWT header is explicitly typed as
openidvci-issuer-metadata+jwt. - Validate the JWT signature using the
x5ccertificate chain in the JWT header and the configured trust anchor for the France Identité environment. - Verify
iss,sub, andcredential_issuermatch the expected issuer identifier. - Verify the metadata is fresh enough for local policy using
iatand any cache policy.
Relevant Fields¶
| Field | Description |
|---|---|
credential_issuer |
Issuer identifier — used as the aud in the proof JWT |
challenge_endpoint |
URL of the challenge endpoint (Step 3) |
token_endpoint |
URL of the token endpoint (Step 4) |
nonce_endpoint |
URL of the nonce endpoint (Step 5) |
credential_endpoint |
URL of the credential endpoint (Step 6) |
credential_request_encryption.jwks |
Embedded JSON Web Key Set containing the issuer encryption key for credential request JWE encryption |
credential_request_encryption.encryption_required |
true in this integration — the credential request body MUST be encrypted as a compact JWE |
credential_response_encryption |
Algorithms the issuer accepts when encrypting the credential response to the wallet |
credential_response_encryption.encryption_required |
false in this integration — response encryption is optional; providing credential_response_encryption in the credential request is recommended to keep the issued credential confidential in transit |
credential_configurations_supported |
Object describing each supported credential type and its format |
Base URL and Environments¶
All network exchanges in Steps 3–6 are relative to a common base URL.
| Environment | Base URL |
|---|---|
| Playground | https://api.playground.france-identite.gouv.fr/poc/idakto/openid4vci/v2 |
| Partenaires | TBD |
| Production | TBD |
All request examples in this document use the placeholder {issuer_base_url}. Substitute the appropriate environment value before making requests.
Flow Overview¶
Sequence Diagram¶
sequenceDiagram
participant FIA as France Identité App
participant ACM as Android CredentialManager
participant CWA as Calling Wallet App
participant CWB as Calling Wallet Backend
participant FI as France Identité Backend / Issuer
CWA->>FIA: Deeplink
FIA->>ACM: CredentialManager request
ACM->>CWA: Wallet chooser + credential request data
CWA->>FI: Challenge Request
FI->>CWA: Challenge Response (attestation_challenge + DPoP nonce)
CWA->>CWB: Client Attestation Request (with attestation_challenge)
CWB->>CWA: Client Attestation Response (client_attestation JWT)
CWA->>CWA: Build DPoP JWT (nonce = DPoP nonce) + Client Attestation PoP JWT (challenge = attestation_challenge)
CWA->>FI: Token Request (DPoP + OAuth-Client-Attestation + OAuth-Client-Attestation-PoP + pre-authorized_code)
FI->>CWA: Token Response (access_token, token_type: DPoP)
CWA->>FI: Nonce Request
FI->>CWA: Nonce Response (c_nonce + DPoP nonce)
CWA->>CWA: Build DPoP JWT (nonce = DPoP nonce) + credential proof (nonce = c_nonce) + encrypt request JWE
CWA->>FI: Credential Request (Authorization: DPoP + DPoP header + encrypted JWE body)
FI->>CWA: Credential Response (encrypted JWE)
CWA->>CWA: Decrypt credential response JWE
Step Summary¶
| Step | Actor | Endpoint | Method | Key Inputs | Key Outputs |
|---|---|---|---|---|---|
| 1 — Deeplink | Calling Wallet App | France Identité App | Intent | credential_type, wallet_name |
— |
| 2 — Credential Manager | France Identité App | Android Credential Manager | API | — | pre-authorized_code, credential_issuer |
| 3 — Challenge | Wallet | {issuer_base_url}/challenge |
POST | — | attestation_challenge, DPoP nonce |
| 4 — Token | Wallet | {issuer_base_url}/token |
POST | DPoP, Client Attestation, Attestation PoP, pre-authorized_code |
access_token |
| 5 — Nonce | Wallet | {issuer_base_url}/nonce |
POST | — | c_nonce, DPoP nonce |
| 6 — Credential | Wallet | {issuer_base_url}/credential |
POST | DPoP, Authorization: DPoP, encrypted JWE body |
Encrypted JWE credential |
Step 1 — Deeplink Invocation¶
The calling wallet application launches the France Identité app using a deeplink. The flow is never initiated from the France Identité app itself.
Deeplink Format¶
Deeplink Parameters¶
| Parameter | Type | Required | Description | Example |
|---|---|---|---|---|
wallet_name |
string | YES | Display name of the calling wallet application | MyWallet |
credential_type |
string | YES | Identifier of the credential type being requested | eu.europa.ec.av.1 |
credential_format |
string | YES | Format of the credential being requested (mso_mdoc or sd+jwt) | mso_mdoc |
Example¶
https://idp.france-identite.gouv.fr/credentials-request#wallet_name=MyWallet&credential_type=eu.europa.ec.av.1&credential_format=mso_mdoc
Step 2 — Credential Manager Request¶
After receiving the deeplink, the France Identité app guides the user through an authentication process, obtains a credential offer from the backend, and passes the issuance request to Android Credential Manager.
User Flow¶
![]() The user is presented with an explanation of the process. |
![]() The user authenticates and authorises the France Identité backend to provision the requested credential. |
![]() The France Identité app receives the credential offer URL, retrieves the offer, and extracts the pre-authorized_code.
|
![]() The France Identité app passes the credential request to Android Credential Manager. The selected wallet receives this payload and begins the OID4VCI exchanges. |
Credential Manager Provider Registration¶
To appear in the wallet chooser and receive the credential request, the wallet must register as a Digital Credential provider in Android Credential Manager. Refer to the official Android documentation for implementation details:
Android Credential Manager — Digital Credentials provider guide
Once registered, the wallet receives the credential request payload described below when the user selects it in the chooser. The OID4VCI flow (Steps 3–6) is then initiated from within the provider callback.
Credential Request Structure¶
The payload delivered to the wallet through Credential Manager is assembled by the France Identité app. The wallet receives it as-is and must not attempt to modify it.
{
"requests": [
{
"protocol": "openid4vci1.0",
"data": {
"credential_issuer": "<credential_issuer>",
"credential_configuration_ids": [
"eu.europa.ec.av.1"
],
"grants": {
"urn:ietf:params:oauth:grant-type:pre-authorized_code": {
"pre-authorized_code": "<pre-authorized_code>"
}
}
}
}
]
}
Payload Fields¶
| Field | Type | Description |
|---|---|---|
requests |
array\ | List of credential issuance requests. Currently always contains exactly one entry. |
requests[].protocol |
string | Issuance protocol identifier. Always openid4vci1.0. |
requests[].data.credential_issuer |
string | Credential issuer URL. This value MUST match the credential_issuer identifier from the signed issuer metadata. |
requests[].data.credential_configuration_ids |
array\ |
Identifiers of the requested credential types (e.g. ["eu.europa.ec.av.1"]). |
requests[].data.grants[...].pre-authorized_code |
string | Short-lived, single-use authorization code used to obtain an access token at the token endpoint. Treat as a secret. |
Step 3 — Challenge Request¶
The wallet calls the challenge endpoint to obtain the values it needs before building the token request: an attestation_challenge for the Client Attestation PoP and a server-provided DPoP nonce for the token request's DPoP JWT.
Extension: The challenge endpoint is not part of the core OID4VCI 1.0 flow. It exists specifically to support OAuth 2.0 Attestation-Based Client Authentication: the
attestation_challengeit returns is the server-generated nonce bound into theOAuth-Client-Attestation-PoPJWT at the token request step.
Request¶
No request body or authentication headers are required.
Response¶
Response Headers¶
| Header | Description |
|---|---|
DPoP-Nonce |
Server-issued nonce to include in the nonce claim of the token request DPoP JWT |
In some integration traces, intermediary logging does not expose the
DPoP-Nonceresponse header even though the next DPoP proof contains a server-provided nonce. Treat the nonce value as opaque response metadata. Do not reuseattestation_challengeas the DPoP nonce.
Response Body¶
{
"attestation_challenge": "hEOhASagV4JQ0Uqc0Q5gryefi5ScjgJKWxpqGG-RWEACBR8szWiB9fya-KkGAyEbiWBnxo5XSumw3Zj3eZZpek8j7-DWMOZfPYkAsHFczNzjoikLN3tSc2Lwa0-VEndA"
}
| Field | Type | Description |
|---|---|---|
attestation_challenge |
string | Server-generated nonce to include in the challenge claim of the Client Attestation PoP JWT |
Two nonces, two purposes.
attestation_challenge(response body) goes into the Client Attestation PoP. The DPoP nonce goes into the DPoP JWT for the token request. They are independent values.
Step 4 — Token Request¶
The wallet exchanges the pre-authorized_code for a DPoP-bound access token. Beyond the standard OAuth2 parameters, the request carries three security headers: a DPoP proof, a Client Attestation, and a Client Attestation PoP.
Request¶
POST {issuer_base_url}/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
DPoP: <dpop_jwt>
OAuth-Client-Attestation: <client_attestation_jwt>
OAuth-Client-Attestation-PoP: <client_attestation_pop_jwt>
grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Apre-authorized_code
&pre-authorized_code=w9HRltH_WKYtHHf2RttR_7U2-YcQ5fX7iokppzyZxZQ
Body Parameters¶
| Parameter | Type | Description |
|---|---|---|
grant_type |
string | Always urn:ietf:params:oauth:grant-type:pre-authorized_code |
pre-authorized_code |
string | The pre-authorized code received in the Credential Manager payload (Step 2) |
DPoP Header¶
The DPoP header is a signed JWT that binds the token request to the wallet's key pair and to the target endpoint. A fresh DPoP JWT MUST be generated for each request.
JWT Header¶
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"kid": "<wallet_key_id>",
"x": "<x_coordinate>",
"y": "<y_coordinate>"
}
}
The jwk field contains the Ephemeral Session Key public key. The corresponding private key signs the JWT.
JWT Payload¶
Standard DPoP proof JWT claims (htm, htu, iat, jti) are defined in RFC 9449 §4.2. The integration-specific claim is:
{
"htm": "POST",
"htu": "{issuer_base_url}/token",
"iat": <unix_timestamp>,
"nonce": "<dpop_nonce>",
"jti": "<unique_identifier>"
}
| Claim | Description |
|---|---|
htm |
HTTP method: POST |
htu |
Full URI of the token endpoint |
iat |
Issuance timestamp |
nonce |
The DPoP nonce value supplied for the token request in Step 3 |
jti |
Unique identifier preventing DPoP JWT reuse |
OAuth-Client-Attestation Header¶
The OAuth-Client-Attestation header contains a JWT issued by the wallet backend. It attests to the wallet application's identity and binds the attestation to the Ephemeral Session Key via cnf.jwk. The wallet presents this JWT as received from its backend — it does not generate it itself.
JWT Header¶
{
"typ": "oauth-client-attestation+jwt",
"alg": "ES256",
"x5c": ["<leaf_certificate>", "<intermediate_certificate>", "<root_certificate>"]
}
The x5c array contains the certificate chain used to verify the attestation signature, from leaf to root.
JWT Payload¶
{
"sub": "<client_id>",
"wallet_name": "<wallet_display_name>",
"wallet_link": "<wallet_store_or_info_url>",
"iss": "Android Keystore",
"cnf": {
"jwk": {
"kty": "EC",
"crv": "P-256",
"kid": "<wallet_key_id>",
"x": "<x_coordinate>",
"y": "<y_coordinate>"
}
},
"iat": <unix_timestamp>,
"exp": <unix_timestamp>
}
| Claim | Description |
|---|---|
sub |
The wallet application's registered client identifier |
wallet_name |
Display name of the wallet |
wallet_link |
URL pointing to the wallet's store listing or information page |
iss |
Attestation issuer identifier. The value "Android Keystore" is used when the wallet's attestation signing key is hardware-backed by Android Keystore. Per draft-ietf-oauth-attestation-based-client-auth, this claim would normally be a URI identifying the attestation issuer service; "Android Keystore" is the convention used in this integration for hardware-signed attestations. |
cnf.jwk |
The Ephemeral Session Key public key — identical to the key embedded in the DPoP JWT |
iat / exp |
Validity window of the attestation |
OAuth-Client-Attestation-PoP Header¶
The OAuth-Client-Attestation-PoP header is a JWT signed by the Ephemeral Session Key private key. It proves possession of the key referenced in the Client Attestation (cnf.jwk) and ties this attestation to the current issuance session via the attestation_challenge.
JWT Header¶
{
"typ": "oauth-client-attestation-pop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"kid": "<wallet_key_id>",
"x": "<x_coordinate>",
"y": "<y_coordinate>"
}
}
JWT Payload¶
{
"iss": "<client_id>",
"aud": "{credential_issuer}",
"challenge": "<attestation_challenge>",
"iat": <unix_timestamp>,
"exp": <unix_timestamp>,
"jti": "<unique_identifier>"
}
| Claim | Description |
|---|---|
iss |
The wallet application's client identifier |
aud |
The credential_issuer value from the issuer metadata |
challenge |
The attestation_challenge value from the challenge response body (Step 3) |
iat / exp |
Validity window |
jti |
Unique identifier preventing replay |
Response¶
{
"access_token": "yRP5dc9cdfx4c_Ne8DZP9Uj4htaxUWJ05EFMFRWHIxA",
"expires_in": 896,
"token_type": "DPoP"
}
| Field | Type | Description |
|---|---|---|
access_token |
string | The DPoP-bound access token used in the credential request |
token_type |
string | Always DPoP — the token MUST be used with a DPoP proof, not as a plain Bearer token |
expires_in |
integer | Token lifetime in seconds |
Note: The token response does not contain a
c_nonce. The credential nonce is fetched separately in Step 5.
Step 5 — Nonce Request¶
The wallet fetches a fresh c_nonce from the nonce endpoint. This stage also provides a DPoP nonce for the credential request's DPoP JWT.
Request¶
No request body or authentication headers are required.
Response¶
Response Headers¶
| Header | Description |
|---|---|
DPoP-Nonce |
Server-issued nonce. Include in the nonce claim of the credential request DPoP JWT. |
As with Step 3, do not derive the DPoP nonce from
c_nonce. They are separate opaque values even if a trace or proxy records only the JSON body.
Response Body¶
{
"c_nonce": "hEOhASagV4JQ_PV8-80xj556CJFQG4QEpBpqGG-RWECI21HpF8kSeufcFhLckHU7i7eE65ixQvgnTDjLPhnXA1n6KjLLiF1VtIwUqiLWrSXSBnDLOnz17UfC5nwwqPfV"
}
| Field | Type | Description |
|---|---|---|
c_nonce |
string | Server-generated nonce to include in the nonce claim of the proof JWT inside the credential request body |
Two nonces, two purposes.
c_nonce(response body) binds the credential proof. The DPoP nonce binds the DPoP JWT. They are independent values, same pattern as Step 3.
Step 6 — Credential Request¶
The wallet submits the credential request to the issuer. Two features distinguish this from a standard OID4VCI credential request:
- DPoP-bound authorization — uses
Authorization: DPoP(notBearer), with a DPoP JWT that additionally carries anath(access token hash) claim. - Encrypted request body — the credential request JSON is encrypted as a compact JWE using the issuer's public encryption key, and sent with
Content-Type: application/jwt.
Request¶
Headers¶
POST {issuer_base_url}/credential HTTP/1.1
Authorization: DPoP <access_token>
DPoP: <dpop_jwt>
Content-Type: application/jwt
| Header | Description |
|---|---|
Authorization |
DPoP <access_token> — DPoP scheme, not Bearer |
DPoP |
Fresh DPoP JWT bound to the credential endpoint and access token |
Content-Type |
application/jwt — body is a compact JWE |
DPoP JWT¶
The DPoP JWT for the credential request follows the same structure as the token request, with two differences: the htu points to the credential endpoint, and it includes an additional ath claim.
Header¶
Same structure as the token request DPoP header (see Step 4).
Payload¶
Standard DPoP proof JWT claims (htm, htu, iat, jti) are defined in RFC 9449 §4.2. The integration-specific claims are nonce (Step 5 server-supplied value) and ath (access token binding defined in RFC 9449 §4.3):
{
"htm": "POST",
"htu": "{issuer_base_url}/credential",
"ath": "<base64url(SHA-256(access_token))>",
"iat": <unix_timestamp>,
"nonce": "<dpop_nonce>",
"jti": "<unique_identifier>"
}
| Claim | Description |
|---|---|
htm |
HTTP method: POST |
htu |
Full URI of the credential endpoint |
ath |
Base64url-encoded SHA-256 hash of the raw access_token string, binding this DPoP proof to the specific token |
iat |
Issuance timestamp |
nonce |
The DPoP nonce value supplied for the credential request in Step 5 |
jti |
Unique identifier preventing DPoP JWT reuse |
Request Body — Encrypted JWE¶
The credential request JSON is encrypted as a compact JWE before transmission. The encryption uses the issuer's public key from credential_request_encryption.jwks in the signed issuer metadata (see Issuer Metadata).
Why encryption? The credential request body contains the wallet's binding public key and the proof JWT. Encrypting it prevents any network intermediary from observing or replaying these values.
Compact JWE Structure¶
Compact serialization is defined in RFC 7516 §3.1:
With
ECDH-ES, the Content Encryption Key is derived directly from the key agreement and is never transmitted. The<base64url(Encrypted Key)>segment is always an empty string in the serialized compact JWE.
JWE Header¶
{
"alg": "ECDH-ES",
"enc": "A256GCM",
"kid": "<kid>",
"cty": "application/json",
"epk": {
"kty": "EC",
"crv": "P-256",
"x": "<sender_ephemeral_x>",
"y": "<sender_ephemeral_y>"
}
}
| Field | Description |
|---|---|
alg |
Key agreement algorithm: ECDH-ES |
enc |
Content encryption algorithm: A256GCM |
kid |
Identifies the issuer encryption key to use |
cty |
Content type of the plaintext: application/json |
epk |
Sender's ephemeral public key for the ECDH-ES shared secret derivation |
Issuer Encryption Public Key¶
Read the key from the signed issuer metadata at credential_request_encryption.jwks.keys. Look for the entry with "use": "enc":
{
"kty": "EC",
"crv": "P-256",
"alg": "ECDH-ES",
"use": "enc",
"kid": "<kid>",
"x": "<x_coordinate>",
"y": "<y_coordinate>"
}
Decrypted Payload — Credential Request JSON¶
The plaintext inside the JWE is an OID4VCI credential request extended with credential_response_encryption. The current OID4VCI 1.0 structure uses proofs, where each proof type maps to an array of proof values:
{
"format": "mso_mdoc",
"doctype": "eu.europa.ec.av.1",
"proofs": {
"<proof_type>": [
"<proof_value>"
]
},
"credential_response_encryption": {
"jwk": {
"kty": "EC",
"crv": "P-256",
"alg": "ECDH-ES",
"use": "enc",
"kid": "<response_encryption_key_id>",
"x": "<x_coordinate>",
"y": "<y_coordinate>"
},
"alg": "ECDH-ES",
"enc": "A256GCM"
}
}
| Field | Description |
|---|---|
format |
Requested credential format: mso_mdoc |
doctype |
mdoc document type: eu.europa.ec.av.1 |
proofs |
Proof-of-possession object demonstrating control of the binding key. Each selected proof type maps to one or more proof values. See Proof Types below. |
credential_response_encryption |
Instructs the issuer to encrypt the response using the provided public key |
Proof Types¶
The issuer accepts three proof types for eu.europa.ec.av.1. The wallet selects one and includes the corresponding key and value in the proofs object.
proof_type |
Standard | Signing key | Description |
|---|---|---|---|
jwt |
OID4VCI 1.0 | Credential Binding Key | JWT signed by the Credential Binding Key; the issuer binds the credential to this key |
attestation |
OID4VCI 1.0 | Credential Binding Key | Attestation JWT from a trusted service that attests the Credential Binding Key |
android_keystore_attestation |
Google / Android Digital Credentials proof type | Credential Binding Key | Android Keystore attestation chain proving the Credential Binding Key is hardware-backed |
Proof Type: jwt¶
Defined in OID4VCI 1.0 Appendix F.1. Refer to the specification for the full structure.
Integration-specific values:
| Claim / Field | Required value in this integration |
|---|---|
aud |
The credential_issuer value from the issuer metadata |
nonce |
The c_nonce value from the nonce response body (Step 5) |
Proof Type: attestation¶
Defined in OID4VCI 1.0 Appendix F.3. Refer to the specification for the full structure.
Integration-specific values:
| Claim / Field | Required value in this integration |
|---|---|
aud |
The credential_issuer value from the issuer metadata |
nonce |
The c_nonce value from the nonce response body (Step 5) |
Proof Type: android_keystore_attestation¶
This proof type is defined by Google's Android Digital Credentials guidance and is accepted by France Identité for this integration. For the official Android structure and validation guidance, see Android credential issuer — Keystore attestation.
Credential Response Encryption Key¶
Including credential_response_encryption in the request is optional per the issuer metadata (encryption_required: false), but strongly recommended — omitting it means the credential is returned in plaintext, exposing the bound key material to any intermediary.
The wallet generates a fresh EC P-256 key pair for each issuance session. The public key is sent in credential_response_encryption.jwk; the private key is retained in memory to decrypt the response. Discard the private key after decryption.
Example public key:
{
"kty": "EC",
"crv": "P-256",
"alg": "ECDH-ES",
"use": "enc",
"kid": "response_encryption_test_key",
"x": "TtTXFg3Dx0-TdOktImr31-bF12Ev9JIGszduZODq5g8",
"y": "714EGSZv5ToUKVwq90UTmU8eja8xJI-u9OfU97k4wq4"
}
Response¶
The issuer returns the credential as a compact JWE encrypted with the wallet's credential_response_encryption public key.
Credential Response JWE Header¶
{
"alg": "ECDH-ES",
"enc": "A256GCM",
"kid": "<response_encryption_key_id>",
"epk": {
"kty": "EC",
"crv": "P-256",
"x": "<issuer_ephemeral_x>",
"y": "<issuer_ephemeral_y>"
}
}
The kid matches the key ID the wallet provided in credential_response_encryption.jwk. Decrypt using the corresponding private key.
Decrypted Credential¶
After decryption, the payload contains the issued mso_mdoc credential. For OID4VCI mso_mdoc, the credential value is the base64url-encoded CBOR IssuerSigned structure defined by ISO 18013-5. At issuance time there is no presentation-time DeviceSigned component; the wallet produces DeviceSigned only later during presentation.
Credential Response Payload¶
IssuerSigned Structure¶
IssuerSigned {
nameSpaces: {
"eu.europa.ec.av.1": [
IssuerSignedItem { digestID, random, elementIdentifier: "age_over_18", elementValue: true },
IssuerSignedItem { digestID, random, elementIdentifier: "age_over_21", elementValue: true },
IssuerSignedItem { digestID, random, elementIdentifier: "issuing_country", elementValue: "FR" },
IssuerSignedItem { digestID, random, elementIdentifier: "expiry_date", elementValue: <tdate> }
]
}
issuerAuth: <COSE_Sign1 containing the MSO>
}
Mobile Security Object (MSO)¶
The MSO is the issuer-signed CBOR structure embedded in issuerAuth. It contains the digest of each data element (allowing selective disclosure during presentation) and the device key binding.
| MSO Field | Description |
|---|---|
version |
MSO version string (e.g. "1.0") |
digestAlgorithm |
Digest algorithm used for element hashes: SHA-256 |
valueDigests |
Map from namespace to map of digestID → SHA-256(random \|\| elementValue) — one entry per data element |
deviceKeyInfo.deviceKey |
The Credential Binding Key public key as a COSE_Key, extracted by the issuer from the proof submitted in the credential request |
docType |
Document type: eu.europa.ec.av.1 |
validityInfo.signed |
Timestamp when the MSO was signed |
validityInfo.validFrom |
Credential validity start |
validityInfo.validUntil |
Credential validity end |
Data Elements¶
The eu.europa.ec.av.1 credential type defines the following data elements:
| Element Identifier | Type | Example Value | Description |
|---|---|---|---|
age_over_18 |
boolean | true |
Whether the holder is 18 years of age or older |
age_over_21 |
boolean | true |
Whether the holder is 21 years of age or older |
issuing_country |
string | "FR" |
ISO 3166-1 alpha-2 country code of the issuing authority |
issuing_jurisdiction |
string | "Gironde" |
Jurisdiction within the issuing country (e.g. department or region) |
issuing_authority |
string | "France" |
Name of the authority that issued the credential |
issue_date |
date/time | "2018-08-09T00:00:00Z" |
Date the credential was issued |
expiry_date |
date/time | issuance date + 3 months | Credential expiry date |
The wallet MUST parse the actual IssuerSigned.nameSpaces content rather than assuming a fixed set — future versions of the credential profile may add or remove elements.
Each element is individually selectively disclosable during presentation — the wallet can reveal a subset without exposing the others.
The wallet verifies the issuer's COSE_Sign1 signature over the MSO before storing the credential, validates the issuer certificate chain against the France Identité IACA for the current environment, and verifies that deviceKeyInfo.deviceKey matches the submitted Credential Binding Key.
Error Handling¶
Error Response Format¶
All error responses follow the standard OAuth2 JSON envelope (RFC 6749 §5.2):
HTTP/1.1 <status_code>
Content-Type: application/json
{
"error": "<error_code>",
"error_description": "<human_readable_description>"
}
Error Codes¶
| HTTP Status | Error Code | Likely Cause | Recommended Action |
|---|---|---|---|
| 400 | invalid_request |
Malformed request body or missing required parameter | Inspect the request and fix the parameter |
| 400 | invalid_grant |
pre-authorized_code is expired, already used, or invalid |
Restart the flow from Step 1 |
| 400 | invalid_proof |
Proof is invalid — wrong nonce, wrong audience, bad signature, or invalid certificate chain | Verify the c_nonce and re-generate the proof |
| 400 | use_dpop_nonce |
The DPoP JWT nonce claim is missing or stale |
See DPoP Nonce Retry |
| 401 | invalid_token |
Access token is invalid or was not issued for this client | Request a new token (Step 4) |
| 401 | expired_token |
Access token has expired | Request a new token (Step 4) |
| 400 | unsupported_credential_type |
The requested credential_configuration_id is not supported |
Check the issuer metadata for supported configurations |
| 400 | unsupported_format |
The requested credential format is not supported | Use mso_mdoc |
DPoP Nonce Retry¶
If the server rejects a request with use_dpop_nonce, it means the DPoP JWT's nonce claim is missing or no longer valid. The server includes a fresh DPoP-Nonce header in the error response.
HTTP/1.1 400 Bad Request
Content-Type: application/json
DPoP-Nonce: <fresh_dpop_nonce>
{
"error": "use_dpop_nonce",
"error_description": "Authorization server requires nonce in DPoP proof"
}
The wallet MUST:
1. Extract the new DPoP-Nonce from the error response header.
2. Regenerate the DPoP JWT using the new nonce value.
3. Retry the original request once with the updated DPoP JWT.
Do not restart the entire flow — the pre-authorized_code and access_token remain valid.
Security Considerations¶
The calling wallet is the same application throughout the flow — it sends the deeplink, registers as a Credential Manager provider, and performs the VCI exchanges. All obligations below apply to the single wallet implementation.
The wallet MUST:
- Validate the origin of the incoming Credential Manager request and reject requests from unexpected callers
- Retrieve and validate issuer metadata before initiating any network exchange
- Use HTTPS for all network exchanges
- Treat
pre-authorized_codeandaccess_tokenas secrets — never log them in plaintext - Generate a fresh key pair for
credential_response_encryptionper issuance session and discard the private key after decryption - Bind the credential to the wallet's key (via the device key in the MSO) and validate this binding after decryption
- Verify the issuer's COSE_Sign1 signature on the issued MSO and validate the document signer certificate chain against the France Identité IACA for the active environment
- Prevent replay attacks — validate
c_noncefreshness and enforcejtiuniqueness in DPoP JWTs - Validate certificate chains in
x5cbefore trusting a Client Attestation - Validate Android Keystore attestation chains, roots, revocation status, and hardware/StrongBox security-level claims before relying on
android_keystore_attestation - Store issued credentials in encrypted storage
The wallet SHOULD:
- Verify that the France Identité app is installed and authentic before firing the deeplink
- Avoid leaking
pre-authorized_codevalues through logs,logcat, or IPC - Store the Credential Binding Key in the Android Keystore with hardware-backed protection; use StrongBox if available
- Discard the Ephemeral Session Key after each issuance session completes or fails
Android Integration Notes¶
Client Registration and Onboarding¶
Registration and onboarding for third-party wallet implementations is TBD. Contact the France Identité team to initiate the process.
The following information will be required or assigned:
| Item | Direction | Description |
|---|---|---|
client_id |
Assigned by issuer | Stable identifier used as sub in the Client Attestation and iss in the Attestation PoP |
Certificate chain (x5c) |
Issued to wallet | Used to sign the OAuth-Client-Attestation JWT |
wallet_name |
Provided by wallet | Display name shown to users |
wallet_link |
Provided by wallet | URL to the wallet's store listing or information page |
Credential Manager Integration¶
See Step 2 — Credential Manager Request for the provider registration link and payload structure. The wallet does not need a custom intent filter to receive the credential request — delivery is handled through the Credential Manager service binding.
Deeplink — Sending¶
The wallet is the sender of the deeplink. No intent filter is required on the wallet side. The wallet constructs the France Identité deeplink URL and fires an ACTION_VIEW intent as described in Step 1 — Deeplink Invocation.
Out of Scope¶
The following OID4VCI features are not part of the current integration:
- Multiple credential configurations in a single request
- Deferred issuance
- Authorization code flow
- Batch credential issuance
- DID-based subject identifiers
- Credential refresh
- Credential status validation
Playground Trace Examples¶
The following examples are extracted from a playground issuance session on 2026-05-28. They are intended for implementers who need to compare their own traces against a known-good flow.
Opaque bearer values, compact JWTs, and compact JWEs are shortened where they are not useful to read inline. The credential response encryption private key is included only because it belongs to this expired playground trace and was used to decrypt that session response. Do not reuse it in an implementation.
Annotated VCI flow trace
{
"challenge": {
"request": {
"timestamp": "2026-05-28T16:23:41.488Z",
"method": "POST",
"url": "/france-titres/oid4vci-16-2-13/challenge"
},
"response": {
"timestamp": "2026-05-28T16:23:41.510Z",
"status": 200,
"body": {
"attestation_challenge": "hEOhASagV4JQ0Uqc0Q5gryefi5ScjgJKWxpqGG-RWEACBR8szWiB9fya-KkGAyEbiWBnxo5XSumw3Zj3eZZpek8j7-DWMOZfPYkAsHFczNzjoikLN3tSc2Lwa0-VEndA"
}
}
},
"token": {
"request": {
"timestamp": "2026-05-28T16:23:41.726Z",
"method": "POST",
"url": "/france-titres/oid4vci-16-2-13/token",
"headers": {
"content-type": "application/x-www-form-urlencoded",
"dpop": "<compact dpop+jwt>",
"oauth-client-attestation": "<compact oauth-client-attestation+jwt>",
"oauth-client-attestation-pop": "<compact oauth-client-attestation-pop+jwt>"
},
"body": {
"grant_type": "urn:ietf:params:oauth:grant-type:pre-authorized_code",
"pre-authorized_code": "w9HRltH_WKYtHHf2RttR_7U2-YcQ5fX7iokppzyZxZQ"
}
},
"decoded_dpop": {
"header": {
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"kid": "1_6d99a524-75a4-489e-adfa-5a9bee92d049_fb0724f5-a5da-407c-ba6a-d84ecef67f20",
"x": "tAHecxu06n_un6zR1gXJ0jasgUVRbpB-c0El3RUCqDs",
"y": "NqQBi7iXlA1BmPjKZLxa28moF68e_6RUEXbCCIxTvQU"
}
},
"payload": {
"htm": "POST",
"htu": "https://api.playground.france-identite.gouv.fr/poc/idakto/openid4vci/v2/token",
"iat": 1779985421,
"nonce": "hEOhASagV4JQNCbK1OXwxcPsLnHIULpEBxpqGG-RWEDuvKrd7iq53h9LqCXarknGzYioV-Me46vk54bnSpdPRXV5hnbIzF1IdTzj5O1SpGQaCkROqOrToNVK9vuseVQa",
"jti": "40821509-1715-4b9a-b49c-8fd632dc5bcf"
}
},
"response": {
"timestamp": "2026-05-28T16:23:41.752Z",
"status": 200,
"body": {
"access_token": "yRP5dc9cdfx4c_Ne8DZP9Uj4htaxUWJ05EFMFRWHIxA",
"expires_in": 896,
"token_type": "DPoP"
}
}
},
"nonce": {
"request": {
"timestamp": "2026-05-28T16:23:41.853Z",
"method": "POST",
"url": "/france-titres/oid4vci-16-2-13/nonce"
},
"response": {
"timestamp": "2026-05-28T16:23:41.861Z",
"status": 200,
"body": {
"c_nonce": "hEOhASagV4JQ_PV8-80xj556CJFQG4QEpBpqGG-RWECI21HpF8kSeufcFhLckHU7i7eE65ixQvgnTDjLPhnXA1n6KjLLiF1VtIwUqiLWrSXSBnDLOnz17UfC5nwwqPfV"
}
}
},
"credential": {
"request": {
"timestamp": "2026-05-28T16:23:42.191Z",
"method": "POST",
"url": "/france-titres/oid4vci-16-2-13/credential",
"headers": {
"authorization": "DPoP yRP5dc9cdfx4c_Ne8DZP9Uj4htaxUWJ05EFMFRWHIxA",
"content-type": "application/jwt; charset=utf-8",
"dpop": "<compact dpop+jwt>"
},
"body": "<encrypted compact JWE credential request>"
},
"decoded_dpop": {
"payload": {
"htm": "POST",
"htu": "https://api.playground.france-identite.gouv.fr/poc/idakto/openid4vci/v2/credential",
"ath": "8_VZ0r-vYFbfUwe79g0V6xhXXLmJps0D0lT6H8V8758",
"iat": 1779985421,
"jti": "7ffb5b21-7900-45aa-afa3-2e7799f137ac"
}
},
"request_jwe_header": {
"alg": "ECDH-ES",
"enc": "A256GCM",
"cty": "application/json",
"kid": "ecdsap256",
"epk": {
"kty": "EC",
"crv": "P-256",
"x": "dACJxQoZdqqHW7Ngj-tFiNM33sCNbXy9EUjV-YnTHCo",
"y": "rr8mXFB9ie0MmbMAsBhAbJV-H2QcbZNk4BHAKaySot0"
}
},
"response": {
"timestamp": "2026-05-28T16:23:42.267Z",
"status": 200,
"headers": {
"content-type": "application/jwt"
},
"body": "<encrypted compact JWE credential response>"
},
"response_jwe_header": {
"alg": "ECDH-ES",
"enc": "A256GCM",
"kid": "response_encryption_test_key",
"epk": {
"kty": "EC",
"crv": "P-256",
"x": "cLoReAr4360JdDZEDevgNTpEfr3HW7rlWbGk7nTPJ1I",
"y": "LcxsJtrj20JcIcHZmP3WaLVuLTYOCn09nH85C_CAFdw"
}
}
},
"credential_response_encryption_public_key": {
"kty": "EC",
"crv": "P-256",
"x": "TtTXFg3Dx0-TdOktImr31-bF12Ev9JIGszduZODq5g8",
"y": "714EGSZv5ToUKVwq90UTmU8eja8xJI-u9OfU97k4wq4",
"alg": "ECDH-ES",
"kid": "response_encryption_test_key",
"use": "enc"
},
"credential_response_encryption_private_key": {
"kty": "EC",
"crv": "P-256",
"x": "TtTXFg3Dx0-TdOktImr31-bF12Ev9JIGszduZODq5g8",
"y": "714EGSZv5ToUKVwq90UTmU8eja8xJI-u9OfU97k4wq4",
"d": "2_uG1rJ3UgV6EhJHhqpIfKER3Fr2QECOIuNe6Uzdcdo"
},
"credential_request_encryption_public_key": {
"kty": "EC",
"crv": "P-256",
"x": "cN9gxxCvgLIU7mmNxE1U4nj2mW6dLgGv8txj5NVvRB8",
"y": "K_ATXIVyzRNAa9hCzJ5icXmotjXMLPjrN66OOMr9C9A",
"alg": "ECDH-ES",
"kid": "ecdsap256",
"use": "enc"
}
}
Decoded playground eu.europa.ec.av.1 credential
{
"docType": "eu.europa.ec.av.1",
"version": "1.0",
"nameSpaces": {
"eu.europa.ec.av.1": {
"issuing_jurisdiction": "Gironde",
"age_over_18": true,
"age_over_21": true,
"expiry_date": "2026-01-24T00:00:00Z",
"issue_date": "2018-08-09T00:00:00Z",
"issuing_authority": "France",
"issuing_country": "FR"
}
},
"issuerAuth": {
"digestAlgorithm": "SHA-256",
"validityInfo": {
"signed": "2026-05-26T11:25:41Z",
"validFrom": "2026-05-26T11:25:41Z",
"validUntil": "2026-08-25T17:25:41Z"
},
"documentSignerCertificate": {
"subject": "CN=DS, OU=0002 1300032621, O=Agence Nationale des Titres Sécurisés, C=FR",
"issuer": "CN=Autorité de Certification France Identité IACA, OU=POUR QUALIFICATION UNIQUEMENT, OU=0002 1300032621, O=Agence Nationale des Titres Sécurisés, C=FR",
"notBefore": "2026-03-04T13:58:07Z",
"notAfter": "2027-06-04T12:58:07Z"
},
"deviceKeyInfo": {
"deviceKey": {
"kty": "EC2",
"crv": "P-256",
"x": "n7N9arHtxn5qvVwaRd1VIGUJ0JvxVNgERpEtO_Ey-TA",
"y": "4jbQbUwrAoujuEQqqx6TQi8V1G0j5YrHiJDVGfzZZns"
}
}
},
"rawNameSpaceItems": {
"format": "CBOR tag 24 encoded issuer-signed items",
"count": 7
}
}
Playground Certificates¶
Obtain the playground IACA certificate from the France Identité EUDIW Unfold Playground documentation. The wallet must configure this certificate as the trust anchor for MSO signature validation before verifying any issued credential.
References¶
| Reference | Description |
|---|---|
| OpenID for Verifiable Credential Issuance 1.0 | Core protocol specification |
| OAuth 2.0 (RFC 6749) | Authorization framework |
| RFC 7516 — JWE | JSON Web Encryption — compact serialization format used for credential request and response encryption |
| RFC 7517 — JWK | JSON Web Key — format used for DPoP jwk, Client Attestation cnf.jwk, and encryption keys |
| RFC 9449 — DPoP | Demonstrating Proof of Possession |
| OAuth 2.0 Attestation-Based Client Authentication | OAuth-Client-Attestation and PoP header specification |
| ISO 18013-5 — mDL / mso_mdoc | CBOR-based mobile document format used for the issued credential |
| Android Credential Manager — Digital Credentials provider guide | Android API for credential issuance mediation — provider implementation guide |
| Android credential issuer — Keystore attestation | Official Android issuer guidance for android_keystore_attestation |
| France Identité — EUDIW Unfold Playground | France Identité playground profile, age credential data elements, and environment certificates |
| EUDI Architecture and Reference Framework | EUDI ARF — defines eu.europa.ec.av.1 credential type |



