SenseCrypt Docs
Concepts

CIBA

Client-Initiated Backchannel Authentication — decoupled, no-browser sign-in where SenseCrypt emails the user a sign-in QR, 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 emails the sign-in QR to the resolved user. The user scans it with the Authenticator app and completes the same on-device face ceremony as an interactive sign-in. Receiving the emailed QR is itself the email-possession proof, and the face match is the biometric factor — so a CIBA sign-in carries the same assurance as a browser one.

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) Email the sign-in QR (universal link)[+ best-effort push to the user's devices] 200 { auth_req_id, expires_in, interval } User opens the emailed QR in the Authenticator(receiving the QR is the email-possession proof) 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's email 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, ≤ 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 — the value is verified for signature, issuer and token type, and the user is taken from its email claim, else resolved from its sub. An access token, a logout token 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.
  • 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. It is not used for poll.
  • 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.
400 slow_downYou polled too fast.Back off, then continue polling.
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

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, so the deployment must offer the mutual TLS listener. tls_client_certificate_bound_access_tokens is mandatory 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.

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 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.
  • 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.

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