SenseCrypt Docs
Concepts

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:

opt [ping delivery] loop [until approved, no faster than interval] alt [poll / ping delivery] [push delivery] POST /v1/idp/oidc/bc-authorizescope=openid, one hint (login_hint),binding_message?, + client auth Authenticate client, resolve delivery mode,resolve hint → email, decide sign-in vs self-signup,create pending OAuthSession (auth_req_id) Alert the user through the app's channels(push · emailed QR · or a POST to your endpoint) 200 { auth_req_id, expires_in, interval } User opens the request in the Authenticator(from the alert, or the app's list of waiting requests) Complete the on-device face ceremony(/payload → /complete) Session → authorized Notify client_notification_token (request is ready) POST {token_endpoint}grant_type=…:ciba, auth_req_id 400 authorization_pending POST {token_endpoint} (auth_req_id) 200 tokens (auth_req_id now spent, single-use) Deliver tokens out-of-band to thenotification endpoint (client never polls) Approve Face proof on device —no biometric data leaves the phone Collect tokens Consumption Device (your backend) SenseCrypt IdP User alert Phone (Authenticator)

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 authentication

Rules for the request:

  • The scope must include openid, and every scope must be one your client is allowed to use.
  • Provide exactly one user hint: login_hint (an email), login_hint_token, or id_token_hint. Supplying zero or more than one is an error. login_hint is the user's email directly. login_hint_token and id_token_hint must both be an id_token this tenant issued to your application — the value is verified for signature, issuer and token type, its aud (and azp, if it has one) must name your client_id, and the user is taken from its email claim, else resolved from its sub. An access token, a logout token, an id_token issued to another application in the tenant, or anything else in either slot resolves to no user: the request is refused with unknown_user_id and no sign-in email is sent. An application that delivers its own alerts must use login_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 with invalid_binding_message.
  • client_notification_token is required for ping and push delivery (it is how SenseCrypt calls you back) and is presented to your endpoint as Authorization: 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. With poll it 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; 0 and omitting it both mean "the default window". A negative or non-integer value is refused with invalid_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 poll interval. Read the effective lifetime off expires_in in the response rather than assuming your ask was honoured.
  • user_code is not used. SenseCrypt is passwordless, so backchannel_user_code_parameter_supported is false and any stray user_code is 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 with requested_expiry if you want a tighter window.
  • interval — minimum seconds between polls (default 5 s; omitted for push).

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 authentication

Handle these responses:

ResponseMeaningWhat to do
200 with tokensThe user approved.Done — the auth_req_id is now spent.
400 authorization_pendingNot approved yet.Keep polling at interval. A DPoP-bound client takes the DPoP-Nonce this answer carries into its next proof.
400 slow_downYou polled too fast.Back off, then continue polling. Carries a DPoP-Nonce for a DPoP-bound client too.
400 expired_tokenThe request expired.Start a new request.
400 access_deniedThe 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 answersResult
2xxDelivered.
3xx / 4xxFinal — 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 failureRetried, 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.

ModeYou poll?Callbackclient_notification_token
pollYes, at intervalnonenot used
pingYes, after the callbacknotify-onlyrequired
pushNotokens 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 [].

ChannelWhat the user receives
App pushA notification on every phone where they are enrolled, naming your application. With a custom mobile app, it comes through your app's Firebase project.
EmailAn 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_hint and login_hint_token are 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"
}
FieldValue
eventAlways request.created.
request_idThe auth_req_id your bc-authorize call returned.
kindciba_approval for a sign-in, face_pki for a face-PKI ceremony.
pki_kindFor a ceremony, public_keys, sign or decapsulate. Otherwise null.
login_hintThe address you sent, trimmed and lowercased.
binding_messageThe binding_message you sent, or null.
linkThe 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_atWhen 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-authorize call returns, so you already hold the request_id it 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 a 5xx is retried. The request stays in the user's list in the Authenticator either way.
  • One alert per request. An identical bc-authorize (same user and binding_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 a slow_down; keep to the advertised cadence (default 5 s).
  • auth_req_id is single-use and time-boxed. After a successful 200, or after expires_in elapses, it's spent — start a fresh request rather than retrying.
  • Exactly one hint. Zero or two of login_hint / login_hint_token / id_token_hint is a request error, and a login_hint_token / id_token_hint that is not an id_token this tenant issued to your application is unknown_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_token and login_hint on every request, poll included. A request without them, or with a token hint, is invalid_request.
  • binding_message is bounded. Over 140 displayable characters, or a character that cannot be shown as plain text (controls, line breaks, bidirectional controls), is refused with invalid_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 request JWT, its jti is remembered until the object expires; presenting the same JWT twice is invalid_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.

On this page