CIBA
Client-Initiated Backchannel Authentication — decoupled, no-browser sign-in where SenseCrypt (or your application) alerts the user, they approve on their phone, and your backend collects the tokens.
SenseCrypt supports CIBA (OpenID Connect Client-Initiated Backchannel Authentication). CIBA is a decoupled flow: your backend starts authentication with only a hint about the user — there is no browser and no redirect. The user approves on their phone, and your backend collects the tokens afterward.
This fits scenarios like call-center verification, kiosk or point-of-sale confirmation, or any server-initiated step-up where the person is not at a browser you control.
How it fits SenseCrypt
When your backend calls the backchannel endpoint, SenseCrypt alerts the resolved user. By default it pushes a notification to the user's enrolled phones and emails them a sign-in QR. Your application can choose push only, email only, or neither, in which case SenseCrypt posts the request to your application and you alert the user yourself (see Alerting the user). Whatever is chosen, the request also waits in the Authenticator app's list until it expires.
The user opens the request in the Authenticator and completes the same on-device face ceremony as an interactive sign-in. The phone's device key proves the device and the face match is the biometric factor, so a CIBA sign-in carries the same assurance as a browser one. For a user who is not enrolled yet (self-signup), receiving the emailed QR is also the email-possession proof.
The overall shape is: start the request (get an auth_req_id), then collect the tokens (poll, ping, or push).
The full decoupled flow — start on the consumption device, approve on the phone, collect the tokens — looks like this:
1. Start the request
Your backend authenticates as a CIBA-enabled client and POSTs to the backchannel authentication endpoint (resolve it from discovery as backchannel_authentication_endpoint; conventionally /v1/idp/oidc/bc-authorize):
POST {backchannel_authentication_endpoint}
scope=openid ...
login_hint={user_email}
binding_message={short_message} # optional, ≤ 140 chars, shown to the user
client_notification_token={token} # required for ping/push and in the cases below, ≤ 1024 chars
# + your client authenticationRules for the request:
- The
scopemust includeopenid, and every scope must be one your client is allowed to use. - Provide exactly one user hint:
login_hint(an email),login_hint_token, orid_token_hint. Supplying zero or more than one is an error.login_hintis the user's email directly.login_hint_tokenandid_token_hintmust both be anid_tokenthis tenant issued to your application — the value is verified for signature, issuer and token type, itsaud(andazp, if it has one) must name yourclient_id, and the user is taken from itsemailclaim, else resolved from itssub. An access token, a logout token, anid_tokenissued to another application in the tenant, or anything else in either slot resolves to no user: the request is refused withunknown_user_idand no sign-in email is sent. An application that delivers its own alerts must uselogin_hint. binding_message(optional) is a short string shown to the user so they can confirm the request matches what they initiated. It is capped at 140 displayable characters (counted after Unicode NFC normalisation) of plain text — control characters, line breaks and bidirectional formatting controls are refused; letters in any script, emoji and their joiner sequences are fine. A message that breaks the rule is refused withinvalid_binding_message.client_notification_tokenis required forpingandpushdelivery (it is how SenseCrypt calls you back) and is presented to your endpoint asAuthorization: Bearer …. It must be a bearer token as RFC 6750 defines one (A-Z a-z 0-9 - . _ ~ + /, optional trailing=; no spaces or line breaks) of at most 1024 characters. Withpollit is required only for a face-PKI ceremony and for an application that delivers its own alerts, where it authenticates the alert SenseCrypt posts to you.requested_expiry(optional) asks for a different request lifetime. It must be a non-negative integer number of seconds;0and omitting it both mean "the default window". A negative or non-integer value is refused withinvalid_request. The value you get is clamped to the deployment's bounds — never above its ceiling (1800 s by default, the same as the default window) and never below the advertised pollinterval. Read the effective lifetime offexpires_inin the response rather than assuming your ask was honoured.user_codeis not used. SenseCrypt is passwordless, sobackchannel_user_code_parameter_supportedisfalseand any strayuser_codeis ignored.- You may request an API audience with
resource/audience, exactly as in the code flow (see Authorization).
On success you receive:
{
"auth_req_id": "…",
"expires_in": 1800,
"interval": 5
}auth_req_id— the opaque handle you present when polling. It is single-use.expires_in— seconds until the request expires (default 1800 s). The lifetime is deliberately identical whether the hint resolves to an already-enrolled user or to a self-signup candidate, so the value cannot tell you which — shorten it withrequested_expiryif you want a tighter window.interval— minimum seconds between polls (default 5 s; omitted forpush).
See the bc-authorize API reference.
2. Collect the tokens
Poll (and ping) delivery
Poll the token endpoint with the CIBA grant, no faster than interval:
POST {token_endpoint}
grant_type=urn:openid:params:grant-type:ciba
auth_req_id={auth_req_id}
# + your client authenticationHandle these responses:
| Response | Meaning | What to do |
|---|---|---|
200 with tokens | The user approved. | Done — the auth_req_id is now spent. |
400 authorization_pending | Not approved yet. | Keep polling at interval. A DPoP-bound client takes the DPoP-Nonce this answer carries into its next proof. |
400 slow_down | You polled too fast. | Back off, then continue polling. Carries a DPoP-Nonce for a DPoP-bound client too. |
400 expired_token | The request expired. | Start a new request. |
400 access_denied | The user declined (or the ceremony failed). | Stop. |
The success body is a standard token response (access_token, id_token, token_type: "Bearer", expires_in, scope, and refresh_token if offline_access was granted). All the token semantics apply.
With ping delivery, SenseCrypt POSTs { "auth_req_id" } to your registered notification endpoint (authenticated with your client_notification_token) when the request has an outcome, and you then collect the tokens at the token endpoint exactly as with poll. A refused request is pinged too — the token endpoint then answers access_denied. An expired request is not pinged: a ping client learns of the expiry from its own poll, which answers expired_token, so do not wire your notification endpoint as the only timeout signal. (Only push clients receive an expiry notification — see below.)
Push delivery
With push delivery, SenseCrypt delivers the tokens directly to your notification endpoint out-of-band; a refused or expired request arrives as { "auth_req_id", "error": "access_denied" | "expired_token", "error_description" }. Push clients do not poll the token endpoint — a push client that tries to poll is rejected.
Push is not available to FAPI-profile clients: the FAPI-CIBA profile requires tokens to be collected at the token endpoint under client authentication and the sender-constraint, so a FAPI client registers for poll or ping, and a FAPI-profile issuer advertises backchannel_token_delivery_modes_supported: ["poll", "ping"].
How your endpoint's answer is treated
| Your endpoint answers | Result |
|---|---|
2xx | Delivered. |
3xx / 4xx | Final — never retried, and a redirect is never followed (it could carry the bearer elsewhere). For a push client this loses the approved authorization: start a new request. A ping client can still collect at the token endpoint. |
5xx or a transport failure | Retried, up to 5 attempts in total. |
An approval that lands in the last seconds of the request window is still delivered; a ping client's poll then answers expired_token.
Delivery modes
SenseCrypt advertises poll, ping, and push in its discovery metadata (backchannel_token_delivery_modes_supported: ["poll", "ping", "push"]; a FAPI-profile issuer advertises ["poll", "ping"] — push is unavailable to FAPI clients). Your client is registered for exactly one mode, and that registration — not a request parameter — decides the delivery behavior.
| Mode | You poll? | Callback | client_notification_token |
|---|---|---|---|
poll | Yes, at interval | none | not used |
ping | Yes, after the callback | notify-only | required |
push | No | tokens delivered out-of-band (not available to FAPI-profile clients) | required |
Alerting the user
Each application chooses how SenseCrypt tells the user about a new request: app push, email, both, or neither. Both is the default. Set it in the console on the application's Tokens & Security tab, under Backchannel auth (CIBA) → How the user is alerted, or with user_alert_channels on the application: ["push", "email"] (the default), ["push"], ["email"] or [].
| Channel | What the user receives |
|---|---|
| App push | A notification on every phone where they are enrolled, naming your application. With a custom mobile app, it comes through your app's Firebase project. |
| An email with a QR code and a link that opens the request on their phone. |
Whatever you choose, a request that has not expired also waits in the Authenticator app's list of waiting requests, so a missed notification does not mean a missed sign-in. The list shows only requests for applications the user can still access. A push that could not reach a phone while it was off is dropped once its request has expired, so the user never gets a stale notification.
A self-signup application always alerts by email. A new user has no device yet, and opening the emailed link is how they prove they own the address. Turning email off on such an application, or turning on self-signup while email is off, is refused with 422 user_alerts.email_required_for_signup.
Delivering the alert yourself
With neither channel, SenseCrypt does not contact the user. It posts each new request, a sign-in or a face-PKI ceremony, to your application's user alert endpoint (user_alert_endpoint), and you reach the user your own way: SMS, your own app, a chat message. Turning both channels off without an endpoint is refused with 422 user_alerts.endpoint_required. The endpoint must be an absolute https URL on a public host. An internal address is refused at registration, and the address is checked again when each alert is sent.
Every request from such an application must follow two more rules, or it is refused with 400 invalid_request before anything is created:
- Send
client_notification_token, whatever the delivery mode. SenseCrypt presents it as the bearer on the alert, so your endpoint can tell the alert is for a request you started. - Identify the user with
login_hint.id_token_hintandlogin_hint_tokenare refused. The alert and its link carry the user's address, and SenseCrypt sends only an address you supplied.
The alert looks like this:
POST {user_alert_endpoint}
Authorization: Bearer {client_notification_token}
Content-Type: application/json
{
"event": "request.created",
"request_id": "3cR1…",
"kind": "ciba_approval",
"pki_kind": null,
"login_hint": "ada@acme.com",
"binding_message": "Order 4182",
"link": "https://…/v1/link/…",
"expires_at": "2026-10-20T09:30:00.123456Z"
}| Field | Value |
|---|---|
event | Always request.created. |
request_id | The auth_req_id your bc-authorize call returned. |
kind | ciba_approval for a sign-in, face_pki for a face-PKI ceremony. |
pki_kind | For a ceremony, public_keys, sign or decapsulate. Otherwise null. |
login_hint | The address you sent, trimmed and lowercased. |
binding_message | The binding_message you sent, or null. |
link | The link the push and the email would have carried. Opened on the user's phone, it opens the request in the Authenticator. Send it only to that user. |
expires_at | When the request expires, in UTC (ISO 8601). Usually carries fractional seconds (up to six digits), so parse it with a full ISO 8601 parser rather than a whole-seconds format. |
How the alert is delivered:
- Once, right after your
bc-authorizecall returns, so you already hold therequest_idit names. - Never retried. Any answer other than
2xx, a timeout or a transport failure is final, and a redirect is never followed. This differs from ping and push token delivery, where a5xxis retried. The request stays in the user's list in the Authenticator either way. - One alert per request. An identical
bc-authorize(same user andbinding_message) repeated within a few seconds is merged into the first request and sends no second alert. A later repeat is a new request with its own alert. - No subject. The body never carries the user's
sub.
FAPI 2.0/CIBA clients
An application on the FAPI 2.0/CIBA security profile with a CIBA delivery mode is a FAPI-CIBA client, and four extra rules apply.
1. The authentication request must be signed. Send your parameters as one request=<JWS> (CIBA Core §7.1.1) instead of as form fields. A FAPI application — or any application registered with require_signed_request_object — that sends them in the clear is refused invalid_request. The claims then are the request: same-named form fields are ignored, and only the client-authentication fields are read from the form.
The envelope is mandatory in full — iss, aud, exp, iat, nbf and jti — on top of the ordinary signing rules for a request object (ES256/PS256 for a FAPI client, aud naming the issuer, nbf no more than 60 minutes old, exp no more than 60 minutes after nbf). iss must be your client_id.
// header
{ "typ": "oauth-authz-req+jwt", "alg": "ES256", "kid": "<your key id>" }
// payload
{
"iss": "<your client_id>",
"aud": "https://acme.example.com",
"iat": 1893455400,
"nbf": 1893455400,
"exp": 1893455700,
"jti": "5c81…",
"scope": "openid profile",
"login_hint": "ada@acme.com",
"binding_message": "Order 4182"
}jti is single-use: the same JWS presented twice is invalid_request. Every verification failure at this endpoint is 400 invalid_request with one fixed description, whatever the actual cause — invalid_request_object is not used here.
2. Registered signing keys are mandatory. A FAPI application with a CIBA delivery mode cannot be registered without jwks or jwks_uri (422 fapi_ciba.requires_signing_keys) — every one of its requests is signed and has to be verifiable.
3. Poll or ping only. push is refused at registration (422 fapi.forbids_ciba_push), and a FAPI-profile issuer advertises backchannel_token_delivery_modes_supported: ["poll", "ping"].
4. Tokens are certificate-bound by default, so the deployment must offer the mutual TLS listener. tls_client_certificate_bound_access_tokens is the default requirement for a FAPI-CIBA application (422 fapi_ciba.requires_certificate_binding), and certificate binding itself is only registrable where discovery publishes mtls_endpoint_aliases — otherwise the registration is refused 422 mtls.listener_unavailable. Collect the tokens from the aliased token_endpoint, over the connection carrying your client certificate. See Sender-constrained tokens.
Or accept DPoP-bound CIBA tokens. FAPI-CIBA comes from the mutual TLS lineage (FAPI 1.0 Advanced) and predates DPoP, and every certified FAPI-CIBA variant binds its tokens to the client certificate. FAPI 2.0 itself treats mutual TLS and DPoP as equal sender-constraints, and the CIBA grant binds tokens exactly as the authorization code grant does, so an application may accept DPoP-bound CIBA tokens instead: set ciba_dpop_binding_allowed: true (it needs dpop_bound_access_tokens on — 422 ciba.dpop_binding_requires_dpop otherwise; console: Tokens & Security → Backchannel authentication → Accept DPoP-bound CIBA tokens). The CIBA grant then needs a DPoP proof carrying the server nonce, like every other token request from a FAPI application, and mints token_type: "DPoP" with cnf.jkt; the refresh_token grant binds the same way, and every poll answer (authorization_pending, slow_down) carries the next DPoP-Nonce, so a long wait for the approval never costs a use_dpop_nonce round trip mid-loop. What the acceptance costs is certification, not security: that application stands outside the certified FAPI-CIBA profiles, and the console says so on it. It remains a FAPI 2.0 sender-constrained client, and the issuer keeps supporting the certified, certificate-bound configuration for every other application. If an application registers both the certificate binding and the acceptance, the certificate still wins: a DPoP proof on the same request is verified, but the tokens bind to the certificate.
One more wrinkle worth knowing if you build the client assertion yourself: a FAPI application's private_key_jwt assertion normally has to name the issuer identifier as its aud. A FAPI application registered for CIBA may instead name the token endpoint URL at the token endpoint on the CIBA and refresh_token grants — the FAPI-CIBA lineage — while its other grants, and /par, stay issuer-only.
Edge cases and gotchas
- Respect
interval. Polling faster earns aslow_down; keep to the advertised cadence (default 5 s). auth_req_idis single-use and time-boxed. After a successful200, or afterexpires_inelapses, it's spent — start a fresh request rather than retrying.- Exactly one hint. Zero or two of
login_hint/login_hint_token/id_token_hintis a request error, and alogin_hint_token/id_token_hintthat is not anid_tokenthis tenant issued to your application isunknown_user_id. - Ping/push need a notification token. Without
client_notification_token, a ping/push request is rejected; a token that is not a bearer token (spaces, line breaks) or is over 1024 characters is rejected too. - An application that delivers its own alerts needs
client_notification_tokenandlogin_hinton every request, poll included. A request without them, or with a token hint, isinvalid_request. binding_messageis bounded. Over 140 displayable characters, or a character that cannot be shown as plain text (controls, line breaks, bidirectional controls), is refused withinvalid_binding_message— it must be readable in full on the phone's approval card and in the approval email; keep it short and human-readable.- A signed authentication request is single-use. If your client sends its parameters as a signed
requestJWT, itsjtiis remembered until the object expires; presenting the same JWT twice isinvalid_request. - CIBA requests are rate-limited per client and per client+target-email QR dispatch, so a runaway loop can't spray sign-in emails at a user.
- The user must be enrolled. The hint has to resolve to a user who can complete the face ceremony; a non-enrollable target fails closed like any other gate.
Face-PKI ceremonies
A tenant with face PKI gives each user post-quantum keys in two families — ML-KEM and ML-DSA — derived on the phone from the user's face token and their live face, so only the user's face unlocks them. By default there is a keypair of each family per context and per application. A different context gives different keys. In the default application scope, two applications that ask for the same context get unrelated keys, even in the same tenant. An application with face_pki_tenant_scope_allowed can send face_pki_scope=tenant to ask for tenant keys instead. Every application in the tenant that uses the tenant scope gets the same keys for the same context. See Key scope. An application asks those keys for their public keys, a signature or a shared secret with a ceremony: a CIBA request with extra face_pki_* parameters. There are five. The ceremony kind decides which ones the request needs, and face_pki_scope is optional. The user approves it on the phone with the same face scan as a sign-in. The phone sends the result directly to the application's registered delivery endpoint, signed with the user's ML-DSA key. SenseCrypt brokers the ceremony and never sees the result. The ID token carries a face_pki receipt that names the key scope and the delivery. See Face PKI over CIBA.
Set it up
See the CIBA backchannel integration guide for a concrete request-and-poll example, and OIDC & OAuth 2.0 for client authentication.
Related
- Tokens & sessions — the tokens you collect and how to validate them.
- Authorization — the access gate and requesting an API audience.
- Security profiles — the FAPI 2.0/CIBA profile and its registration rules.
- Signed requests and responses — the request-object signing rules in full.
- Face PKI over CIBA — ask a user's face-locked keys for public keys, a signature or a shared secret.
SCIM 2.0
Provision users and groups into a SenseCrypt tenant from your identity provider using the SCIM 2.0 protocol, with pending-shell enrollment for biometrics, filtering, bulk, and eager revocation.
Multi-tenancy & issuers
Each SenseCrypt tenant is its own OIDC issuer and SAML IdP on its own hostname, with its own signing keys and a hard isolation boundary. Resolve every endpoint from discovery.