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:
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 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 — the value is verified for signature, issuer and token type, and the user is taken from itsemailclaim, else resolved from itssub. An access token, a logout token or anything else in either slot resolves to no user: the request is refused withunknown_user_idand 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 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. It is not used forpoll.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. |
400 slow_down | You polled too fast. | Back off, then continue polling. |
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 |
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 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 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. 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.
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.
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.