SenseCrypt Docs
Integrations

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:

CeremonyWhat the phone returnsUse it to
public_keysThe user's ML-KEM and ML-DSA public keys for a context.Learn the user's keys. Pin them. Encrypt to the user.
signAn ML-DSA signature over a hash that you supply.Get a signature that the user's face approved.
decapsulateThe 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 payments and documents. 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 payments get 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_b64 on 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

Enable face PKI on a tenant

Set three fields when you create the tenant. You cannot change them later.

FieldValuesMeaning
face_pki_enabledtrueEvery user in the tenant has face-locked keys.
face_pki_kem_algorithmml-kem-512, ml-kem-768, ml-kem-1024The ML-KEM parameter set for every user in the tenant.
face_pki_dsa_algorithmml-dsa-44, ml-dsa-65, ml-dsa-87The ML-DSA parameter set for every user in the tenant.

Rules:

  • Send both parameter sets when face_pki_enabled is true. A missing set is refused 422 with code: "face_pki.setting_required".
  • Do not send a parameter set when face_pki_enabled is false. It is refused with code: "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 as immutable_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. An http URL is refused 422 with code: "url.https_required". A private, loopback or link-local host is refused with code: "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". A PATCH that 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 auth_req_id Push and email the link Fetch the ceremony 2. Deliver the signed result 2xx after your checks Complete with the body's hash 3. Collect the tokens ID token with the receipt Face scan, then the key operation Receipt must match the accepted body Your backend SenseCrypt User's phone

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
ParameterCeremonyValue
face_pki_kindallpublic_keys, sign or decapsulate.
face_pki_context_b64allStandard 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_b64signStandard 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_b64decapsulateStandard 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 refused invalid_request.
  • Send client_notification_token in every delivery mode, poll included. 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 sign request with a ciphertext, or a decapsulate request with a hash, is refused invalid_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_token or id_token_hint.
  • If your application sends a signed request object, 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:

FieldCeremonyValue
auth_req_idallThe auth_req_id you received in step 1.
kindallThe ceremony you asked for.
subject_idallThe user's stable subject identifier. It equals the sub of the ID token you collect later.
context_b64allThe context you sent, echoed.
dsa_public_key_spki_b64allThe user's ML-DSA public key for this context, as a SubjectPublicKeyInfo (RFC 9881), base64.
kem_public_key_spki_b64public_keys, decapsulateThe user's ML-KEM public key for this context, as a SubjectPublicKeyInfo (RFC 9935), base64.
signature_b64signThe ML-DSA signature over your hash, base64.
shared_secret_b64decapsulateThe shared secret, base64 of the raw 32 bytes.
kem_ciphertext_sha256_b64decapsulateSHA-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.

  1. Match the request. Find the ceremony whose auth_req_id you started. If there is none, answer 404.
  2. Match the bearer. Compare the bearer with the client_notification_token you sent for that ceremony. Use a constant-time comparison. If they differ, answer 401.
  3. Verify the body signature. Verify X-SenseCrypt-Signature over the exact body bytes with the user's ML-DSA public key.
    • For sign and decapsulate, use the key you pinned for this user and context. Refuse the delivery if dsa_public_key_spki_b64 is not the pinned key.
    • For the first public_keys for 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.
  4. Check the result.
    • kind and context_b64 must equal what you asked for.
    • sign: verify signature_b64 over your hash with the key from step 3.
    • decapsulate: kem_ciphertext_sha256_b64 must equal the SHA-256 of the ciphertext you sent. shared_secret_b64 must equal the secret you encapsulated.
  5. 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_id you already accepted. Answer 2xx again.
  • Refuse a different body for an auth_req_id you already accepted. Answer 409.
  • If you answer 4xx or 5xx, 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 with delivery_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:

  1. kind must equal the ceremony you asked for.
  2. context_sha256_b64 must equal the SHA-256 of your context bytes.
  3. delivery_sha256_b64 must equal the hash you kept in step 5 above.
  4. sub must equal the subject_id in 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:

CodeMeaning
invalid_requestA 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_clientThe tenant has no face PKI, the application has no delivery endpoint, or the application has no CIBA delivery mode.
unknown_user_idThe 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.

CodeMeaningWhat to do
authorization_pending, slow_down, expired_tokenAs for a sign-in.As for a sign-in.
access_deniedThe user cancelled, or the face did not match.Stop.
delivery_failedYour endpoint refused the result or could not be reached, and the user gave up.Check your endpoint's logs. Start a new ceremony.
key_unavailableThe 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_requestThe phone refused the request: the ceremony data was malformed, or the delivery endpoint was not https.Fix the request. Start a new ceremony.
stale_tokenAnother 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_message limits 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

  1. Create a face-PKI tenant. Create a confidential application with a CIBA delivery mode and a delivery endpoint. Enrol a user.
  2. Start a public_keys ceremony with an empty context. Confirm that you receive an auth_req_id.
  3. Approve the request on the phone. Confirm that your endpoint receives a body with two public keys and a valid X-SenseCrypt-Signature.
  4. Collect the tokens. Confirm that the ID token carries face_pki, and that delivery_sha256_b64 equals the hash of the body you accepted. Pin the keys.
  5. Start a sign ceremony for the same context with the SHA-256 of a test message. Confirm that signature_b64 verifies over that hash with the pinned ML-DSA key.
  6. Encapsulate to the pinned ML-KEM key. Start a decapsulate ceremony with the ciphertext. Confirm that shared_secret_b64 equals the secret you encapsulated.

Troubleshooting

  • unauthorized_client at bc-authorize. The tenant was created without face PKI, or the application has no face_pki_delivery_endpoint, or it has no CIBA delivery mode. Face PKI cannot be turned on for an existing tenant.
  • invalid_request that names face_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_request that names face_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-Signature does 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_unavailable on 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.

On this page