Face PKI over CIBA
Ask a user's face-locked post-quantum keys for their public keys, a signature or a shared secret — start a ceremony at the backchannel endpoint, receive the signed result at your own endpoint, and check the receipt in the ID token.
This guide shows how your application uses a user's face-locked keys. Each user of a face-PKI tenant has post-quantum keys in two families, ML-KEM (FIPS 203) and ML-DSA (FIPS 204), with a separate keypair of each for every application and context that asks. The keys are derived on the phone from the user's face token and their live face. None of them is ever sent off the phone.
Your application asks for a key operation with a ceremony. A ceremony is a CIBA request with four extra parameters. The user approves the request on their phone with a face scan. The phone then sends the result directly to your endpoint. SenseCrypt brokers the ceremony. It never sees a signature or a shared secret.
There are three ceremonies:
| Ceremony | What the phone returns | Use it to |
|---|---|---|
public_keys | The user's ML-KEM and ML-DSA public keys for a context. | Learn the user's keys. Pin them. Encrypt to the user. |
sign | An ML-DSA signature over a hash that you supply. | Get a signature that the user's face approved. |
decapsulate | The shared secret from an ML-KEM ciphertext that you supply. | Establish a key that only the user's face can unlock. |
Keys are per application and per context — even in the same tenant. Two applications that ask for the same context get different keys. Run public_keys once for each context before you use sign or decapsulate with it. See Key contexts.
Key contexts
The phone derives a distinct keypair for each application and for each context you send in face_pki_context_b64.
The rules:
- A different context gives different keys. Use one context per purpose — for example
paymentsanddocuments. A signing key for one purpose never signs for another. - A different application gives different keys, even for the same context, and even in the same tenant. Two applications in one tenant that both ask for the context
paymentsget two unrelated keypairs. One application cannot learn or use the keys of another. - The same application and the same context always give the same keys. A photo update or a sign-in does not change them.
- The empty context is a context. It gives its own keys. Send
face_pki_context_b64on every ceremony, empty or not. - The context is yours. It is at most 255 bytes of opaque data. SenseCrypt stores it only for the ceremony in flight. The phone never shows it to the user. The receipt carries only its SHA-256.
To use a new key for the same purpose, choose a new context. There is no other way to rotate a user's keys.
Prerequisites
- A tenant with face PKI. Face PKI is set when the tenant is created. See Enable face PKI on a tenant.
- A confidential OIDC application with a CIBA delivery mode (
poll,pingorpush) and a delivery endpoint. See Register a delivery endpoint. - An enrolled user. The user must have the Authenticator app and a face token in this tenant.
Enable face PKI on a tenant
Set three fields when you create the tenant. You cannot change them later.
| Field | Values | Meaning |
|---|---|---|
face_pki_enabled | true | Every user in the tenant has face-locked keys. |
face_pki_kem_algorithm | ml-kem-512, ml-kem-768, ml-kem-1024 | The ML-KEM parameter set for every user in the tenant. |
face_pki_dsa_algorithm | ml-dsa-44, ml-dsa-65, ml-dsa-87 | The ML-DSA parameter set for every user in the tenant. |
Rules:
- Send both parameter sets when
face_pki_enabledistrue. A missing set is refused422withcode: "face_pki.setting_required". - Do not send a parameter set when
face_pki_enabledisfalse. It is refused withcode: "face_pki.requires_enabled". - The deployment's licence must include Face PKI. If it does not, the tenant is refused
400 face_pki_unavailable. PATCH /v1/admin/tenants/{id}refuses all three fields asimmutable_field. To use other parameter sets, create a new tenant.
The parameter sets are fixed per user, so they apply to every application in the tenant. Which endpoint receives a result is set per application. See Managing tenants for the create request.
A user whose face token predates face PKI, for example an imported one, gains keys at their next face scan. Any sign-in or ceremony counts, and a ceremony that finds such a token completes on the keys it just sealed.
Register a delivery endpoint
Set face_pki_delivery_endpoint on the application. The phone POSTs each ceremony's result to this URL.
Rules:
- The URL must be absolute and
https. AnhttpURL is refused422withcode: "url.https_required". A private, loopback or link-local host is refused withcode: "url.private_host". - The application must have a CIBA delivery mode. An endpoint on an application without one is refused with
code: "face_pki.requires_ciba". APATCHthat clears the delivery mode while an endpoint is set is refused in the same way. - The tenant must have face PKI. Otherwise the endpoint is refused with
code: "face_pki.tenant_disabled". - You can change the endpoint at any time. A ceremony that is in flight keeps the endpoint it started with.
- Remove the endpoint to stop ceremonies for this application. Sign-ins are not affected.
The phone POSTs to this URL from outside your network. SenseCrypt screens the host name at registration only. Make sure the endpoint is reachable from the internet, and that it does the checks in step 2.
How a ceremony runs
The numbered steps are yours. The sections below cover each one.
1. Start the ceremony
Authenticate as your application and POST to the backchannel authentication endpoint (backchannel_authentication_endpoint in discovery; conventionally /v1/idp/oidc/bc-authorize). Send the usual CIBA fields and the ceremony parameters:
POST {backchannel_authentication_endpoint}
scope=openid
login_hint={user_email}
binding_message={what the user approves} # required on a ceremony
client_notification_token={token} # required on a ceremony, in every delivery mode
face_pki_kind=public_keys | sign | decapsulate
face_pki_context_b64={base64 context} # required; can be empty
face_pki_payload_hash_b64={base64 hash} # sign only
face_pki_kem_ciphertext_b64={base64 ciphertext} # decapsulate only
# + your client authentication| Parameter | Ceremony | Value |
|---|---|---|
face_pki_kind | all | public_keys, sign or decapsulate. |
face_pki_context_b64 | all | Standard base64 of the key context: 0 to 255 bytes. Send it on every ceremony. An empty context is a valid context. |
face_pki_payload_hash_b64 | sign | Standard base64 of the hash to sign: exactly 32 or 64 bytes (SHA-256 or SHA-512 size). The phone signs these bytes verbatim. |
face_pki_kem_ciphertext_b64 | decapsulate | Standard base64 of an ML-KEM ciphertext for the user's public key: exactly 768 bytes for ml-kem-512, 1088 for ml-kem-768, or 1568 for ml-kem-1024. |
Rules:
- Send
binding_message. It is optional on a sign-in and required on a ceremony. The phone shows it on the approval screen. A ceremony without one is refusedinvalid_request. - Send
client_notification_tokenin every delivery mode,pollincluded. The phone presents it as the bearer when it delivers the result. Choose a fresh random value for each ceremony. - Send a parameter only for the ceremony that uses it. A
signrequest with a ciphertext, or adecapsulaterequest with a hash, is refusedinvalid_request. The error description names the parameter. - Send the operand in standard base64. Bad base64, a wrong length, or a context over 255 bytes is refused
invalid_request. - Use the same hint rules as a sign-in: exactly one of
login_hint,login_hint_tokenorid_token_hint. - If your application sends a signed
requestobject, put the four parameters in it as string claims. The claims are used. Form fields of the same name are ignored.
The response is the same as for a sign-in: auth_req_id, expires_in and interval. The request lifetime and the poll interval are the same too.
SenseCrypt sends a push notification to the user's phone and emails the link. The push names the ceremony — for example "Acme requests your signature" — and the application. The push and the email never carry the context, the hash or the ciphertext. Only the phone's signed request for the ceremony gets them.
The hash is what the user signs, so the message is what the user approves. For sign, put the meaning of the hash into binding_message. The user sees the message, not the hash.
2. Receive the result at your endpoint
After the face scan, the phone POSTs the result to face_pki_delivery_endpoint:
POST {face_pki_delivery_endpoint}
Content-Type: application/json
Authorization: Bearer {client_notification_token}
X-SenseCrypt-Signature: {base64 ML-DSA signature over the exact body bytes}The body carries these fields:
| Field | Ceremony | Value |
|---|---|---|
auth_req_id | all | The auth_req_id you received in step 1. |
kind | all | The ceremony you asked for. |
subject_id | all | The user's stable subject identifier. It equals the sub of the ID token you collect later. |
context_b64 | all | The context you sent, echoed. |
dsa_public_key_spki_b64 | all | The user's ML-DSA public key for this context, as a SubjectPublicKeyInfo (RFC 9881), base64. |
kem_public_key_spki_b64 | public_keys, decapsulate | The user's ML-KEM public key for this context, as a SubjectPublicKeyInfo (RFC 9935), base64. |
signature_b64 | sign | The ML-DSA signature over your hash, base64. |
shared_secret_b64 | decapsulate | The shared secret, base64 of the raw 32 bytes. |
kem_ciphertext_sha256_b64 | decapsulate | SHA-256 of the ciphertext the phone decapsulated, base64. |
For example, a sign delivery:
{
"auth_req_id": "…",
"kind": "sign",
"subject_id": "RN9I7U-blYcHcYYe3cPRLPlfU_NWgdVO",
"context_b64": "cGF5bWVudHM=",
"dsa_public_key_spki_b64": "MIIHsjALBglghkgBZQMEAxIDggehAP…",
"signature_b64": "…"
}Checks your endpoint must make
Make these checks in this order. Refuse the delivery at the first check that fails. Do not use the result before step 3 in Collect the tokens has passed.
- Match the request. Find the ceremony whose
auth_req_idyou started. If there is none, answer404. - Match the bearer. Compare the bearer with the
client_notification_tokenyou sent for that ceremony. Use a constant-time comparison. If they differ, answer401. - Verify the body signature. Verify
X-SenseCrypt-Signatureover the exact body bytes with the user's ML-DSA public key.- For
signanddecapsulate, use the key you pinned for this user and context. Refuse the delivery ifdsa_public_key_spki_b64is not the pinned key. - For the first
public_keysfor a user and context, use the key in the body. Do not pin it yet. Pin it after the receipt check in step 3 below. - For a later
public_keys, refuse a body whose keys differ from the pinned ones.
- For
- Check the result.
kindandcontext_b64must equal what you asked for.sign: verifysignature_b64over your hash with the key from step 3.decapsulate:kem_ciphertext_sha256_b64must equal the SHA-256 of the ciphertext you sent.shared_secret_b64must equal the secret you encapsulated.
- Accept. Answer
2xx. Keep the SHA-256 of the exact body bytes. You need it for the receipt.
The bearer and the auth_req_id only correlate. SenseCrypt knows both. They do not authenticate the phone. The signature does.
A first public_keys trusts the phone that answers first. Only the receipt in the ID token proves that the device the user holds completed that delivery. Pin keys only after the receipt matches. Never use keys from a delivery whose receipt has not arrived.
Resends and refusals
- The phone re-sends the same bytes after a lost response. Accept a byte-identical resend for an
auth_req_idyou already accepted. Answer2xxagain. - Refuse a different body for an
auth_req_idyou already accepted. Answer409. - If you answer
4xxor5xx, or the phone cannot reach you, the phone shows "Not delivered". The user can retry. A retry sends the same bytes; it does not run a second face scan. If the user gives up, the ceremony ends withdelivery_failed.
3. Collect the tokens and check the receipt
After you accept the delivery, the phone completes the request. Collect the tokens as for a sign-in: poll the token endpoint, wait for the ping, or receive the push. The ID token carries a face_pki claim:
{
"iss": "https://acme.sensecrypt.com",
"sub": "RN9I7U-blYcHcYYe3cPRLPlfU_NWgdVO",
"face_pki": {
"kind": "sign",
"context_sha256_b64": "…",
"delivery_sha256_b64": "…"
}
}Check the claim before you use the result:
kindmust equal the ceremony you asked for.context_sha256_b64must equal the SHA-256 of your context bytes.delivery_sha256_b64must equal the hash you kept in step 5 above.submust equal thesubject_idin the delivery.
If all four match, the result is final. Commit a first-use pin now. Every ID token of a ceremony carries face_pki. An ID token of a sign-in never does. A user attribute named face_pki cannot produce this claim.
The receipt closes one gap. A party that holds the bearer and the auth_req_id could POST a body to your endpoint before the phone does. Your endpoint would accept it. The phone's own delivery would then be refused as a different body, and the phone would never complete the request. Without a completion there is no ID token, and without the receipt you never commit the pin.
Errors
At bc-authorize, a ceremony is refused before any email or push is sent:
| Code | Meaning |
|---|---|
invalid_request | A malformed or misplaced parameter, a context over 255 bytes, a wrong hash or ciphertext length, a missing binding_message, or a missing client_notification_token. The description names the parameter. |
unauthorized_client | The tenant has no face PKI, the application has no delivery endpoint, or the application has no CIBA delivery mode. |
unknown_user_id | The hint does not resolve to an enrolled user. On a self-signup application the request takes the same path as a sign-in for an unknown address, so a ceremony cannot reveal who is enrolled. |
At the token endpoint, and in a push client's error payload, a ceremony's own failures keep their names. A sign-in never returns them.
| Code | Meaning | What to do |
|---|---|---|
authorization_pending, slow_down, expired_token | As for a sign-in. | As for a sign-in. |
access_denied | The user cancelled, or the face did not match. | Stop. |
delivery_failed | Your endpoint refused the result or could not be reached, and the user gave up. | Check your endpoint's logs. Start a new ceremony. |
key_unavailable | The user's face token cannot provide the key this ceremony needs — for example, the ciphertext does not fit the user's key. | Check the tenant's parameter sets. Start a new ceremony. |
invalid_request | The phone refused the request: the ceremony data was malformed, or the delivery endpoint was not https. | Fix the request. Start a new ceremony. |
stale_token | Another session updated the user's face token while the ceremony ran. No receipt was minted, so no pin was committed. | Start a new ceremony. |
See Error codes for the full tables.
Limits and rules
- Ceremonies are rate-limited per user across all applications in the tenant. A user who is asked too often gets no further ceremonies for a while.
- The
binding_messagelimits of a sign-in apply: at most 140 displayable characters of plain text. - A ceremony expires like a sign-in request. Read
expires_in. - A repeated request collapses onto a pending ceremony only if it asks for the same thing with the same
client_notification_token. A sign-in never collapses onto a ceremony. - The Authenticator app must be at least the version that supports face PKI. An older app is told to update. The ceremony is not run.
Verify it worked
- Create a face-PKI tenant. Create a confidential application with a CIBA delivery mode and a delivery endpoint. Enrol a user.
- Start a
public_keysceremony with an empty context. Confirm that you receive anauth_req_id. - Approve the request on the phone. Confirm that your endpoint receives a body with two public keys and a valid
X-SenseCrypt-Signature. - Collect the tokens. Confirm that the ID token carries
face_pki, and thatdelivery_sha256_b64equals the hash of the body you accepted. Pin the keys. - Start a
signceremony for the same context with the SHA-256 of a test message. Confirm thatsignature_b64verifies over that hash with the pinned ML-DSA key. - Encapsulate to the pinned ML-KEM key. Start a
decapsulateceremony with the ciphertext. Confirm thatshared_secret_b64equals the secret you encapsulated.
Troubleshooting
unauthorized_clientatbc-authorize. The tenant was created without face PKI, or the application has noface_pki_delivery_endpoint, or it has no CIBA delivery mode. Face PKI cannot be turned on for an existing tenant.invalid_requestthat namesface_pki_context_b64. The context is missing, is not base64, or is over 255 bytes. Send the parameter on every ceremony, even when it is empty.invalid_requestthat namesface_pki_kem_ciphertext_b64. The ciphertext length does not match the tenant's ML-KEM parameter set. Encapsulate to the user's pinned key, not to a key of another size.- Your endpoint never receives a delivery. The phone could not reach it. Check that the URL is reachable from the internet and presents a valid certificate. The user sees "Not delivered" and can retry.
- The signature in
X-SenseCrypt-Signaturedoes not verify. Verify over the exact body bytes as received. Do not re-serialize the JSON first. - The receipt does not match. Compare with the hash of the exact body bytes you accepted. If you accepted a body from another party first, the phone's delivery was refused and no receipt will ever match. Start a new ceremony.
key_unavailableon a tenant that has face PKI. The user's app cannot use the tenant's parameter sets, because the build is older than they are, or the ciphertext does not fit the user's key. Ask the user to update the app, then start the ceremony again. A face token minted before the tenant had face PKI is not the cause: a current app seals the keys inside the ceremony and completes on them.- The phone shows "Update required". The Authenticator app is older than face PKI. The user must update it.
Related
- CIBA — the decoupled flow a ceremony runs on, and its delivery modes.
- CIBA backchannel — the plain sign-in request and the polling loop.
- Face-PKI ceremony flow — the mobile SDK side, if you build your own Authenticator app.
- Managing tenants — the create request that sets face PKI.
- Error codes — every code above, in context.
CIBA backchannel
Start a decoupled, no-browser sign-in with CIBA — call the backchannel authentication endpoint, then poll the token endpoint until the user approves on their phone.
Guides
Task-focused how-tos for building on SenseCrypt — managing tenants, custom domains, customizing claims, RBAC, group-based access, branding, your own Authenticator app, refresh tokens, key rotation, testing, and security hardening.