CIBA backchannel
Start a decoupled, no-browser sign-in with CIBA — call the backchannel authentication endpoint, then poll the token endpoint until the user approves on their phone.
This guide implements CIBA (Client-Initiated Backchannel Authentication): your backend starts authentication with a user hint, SenseCrypt emails the sign-in QR to the user, the user approves with an on-device face scan, and your backend collects the tokens. There is no browser and no redirect. For the model, see CIBA.
The emailed QR + face scan is one of SenseCrypt's three sign-in methods. Every method pairs a hardware-bound device key with a live face check — the key proves the device, the face proves the person — and SenseCrypt is built on real FIDO2/WebAuthn passkeys (ES256). On the passkey method that possession factor is a FIDO2/WebAuthn passkey, the phishing-resistant path (WebAuthn binds each assertion to its origin); your backend collects the same token set whichever method the user used.
Prerequisites
- A SenseCrypt tenant.
- A CIBA-enabled OIDC client registered in the admin console, configured for a delivery mode (
poll,ping, orpush— push is not available to FAPI-profile clients, which use poll or ping). CIBA requires a confidential client — SPA/public clients can't use it.
1. Start the backchannel request
Here is the whole ceremony end to end — your backend never opens a browser, and the user's biometric never leaves their phone:
Authenticate as your client and POST to the backchannel authentication endpoint (resolve backchannel_authentication_endpoint from discovery; conventionally /v1/idp/oidc/bc-authorize):
curl -X POST {backchannel_authentication_endpoint} \
-d client_id={client_id} \
-d client_secret={client_secret} \
-d scope="openid profile" \
-d login_hint="jane@acme.com" \
-d binding_message="Approve login to Acme"Rules:
scopemust includeopenid, and every scope must be one your client may use.- Supply exactly one user hint:
login_hint(email),login_hint_token, orid_token_hint. The two token slots must carry anid_tokenthis tenant issued — anything else (an access token, for instance) isunknown_user_idand no email is sent. binding_message(optional) is shown to the user to confirm the request.- Include
client_notification_tokenforping/pushdelivery — a bearer token (RFC 6750b64token: no spaces or line breaks) of at most 1024 characters; SenseCrypt presents it back to your endpoint asAuthorization: Bearer …. An optionalbinding_messageis capped at 140 displayable characters of plain text (invalid_binding_messageotherwise). (Auser_codeis not supported — SenseCrypt is passwordless, so anyuser_codesent is ignored.)
The response:
{
"auth_req_id": "…",
"expires_in": 1800,
"interval": 5
}auth_req_ididentifies this request at the token endpoint. It is single-use.expires_inis how long you have to complete it (1800s by default, the same value whether or not the user is already enrolled). Sendrequested_expiry— a non-negative integer, clamped to the deployment's bounds — for a tighter window.intervalis the minimum seconds between polls (5s by default). Forpushdelivery,intervalis omitted (push clients don't poll).
SenseCrypt emails the sign-in QR to the resolved user. They scan it with the Authenticator app and complete the face ceremony.
2. Poll for tokens
Poll the token endpoint with the CIBA grant, waiting at least interval seconds between polls:
curl -X POST {token_endpoint} \
-d grant_type=urn:openid:params:grant-type:ciba \
-d auth_req_id={auth_req_id} \
-d client_id={client_id} \
-d client_secret={client_secret}Handle the responses:
| Response | Meaning | Action |
|---|---|---|
200 with tokens | Approved. | Done — auth_req_id is single-use. |
authorization_pending | Not approved yet. | Keep polling at interval. |
slow_down | Polling too fast. | Back off, then keep polling. |
expired_token | The request expired. | Start over. |
access_denied | The user declined. | Stop. |
The pending/terminal codes arrive in the OAuth error envelope, for example:
{ "error": "authorization_pending" }On success you receive the same token set as an interactive sign-in (an ID token and access token, plus a refresh token if offline_access was requested). A CIBA id_token carries no nonce (there was no /authorize request), so don't require one when you validate it.
Polling in code (Python)
Treat authorization_pending/slow_down as "keep going", everything else terminal.
import time, httpx
PENDING = {"authorization_pending", "slow_down"}
def poll_for_tokens(token_endpoint, form, interval, deadline):
while time.monotonic() < deadline:
resp = httpx.post(token_endpoint, data={
"grant_type": "urn:openid:params:grant-type:ciba",
**form, # auth_req_id, client_id, client_secret
})
if resp.status_code == 200:
return resp.json() # tokens — done
code = resp.json().get("error", "")
if code not in PENDING:
raise RuntimeError(f"CIBA failed: {code}") # access_denied / expired_token / …
time.sleep(interval + 1) # respect interval; back off on slow_down
raise TimeoutError("CIBA request expired")Ping and push delivery
ping— SenseCrypt POSTs{ "auth_req_id" }to your registered notification endpoint (withAuthorization: Bearer <client_notification_token>) when the request has an outcome; you then collect tokens at the token endpoint exactly as above (a refused or expired request answersaccess_denied/expired_tokenthere).push— SenseCrypt delivers the tokens directly to your notification endpoint; a refused or expired request arrives as an error payload (access_denied/expired_token). Push clients do not poll (and get nointerval). Push is not available to FAPI-profile clients.
How your endpoint answers matters: a 2xx is delivered; a 3xx or 4xx is final — never retried, a redirect never followed — and for a push client it loses the approved authorization (start a new request); a 5xx or connection failure is retried up to 5 times in total. An approval that lands in the last seconds of the request window is still delivered.
Verify it worked
- Call
bc-authorizewith a test user's email and confirm you get anauth_req_id. - Confirm the user receives the sign-in email and can approve on their phone.
- While pending, confirm your poll returns
authorization_pending; after approval, confirm it returns200with anid_token. - Validate the
id_token(your application's registered algorithm —ES256by default,RS256if you chose it — plusiss,aud,exp) — the same as any sign-in, minus thenoncecheck.
Troubleshooting
invalid_requestatbc-authorize. Usually a missing/duplicate hint — send exactly one oflogin_hint/login_hint_token/id_token_hint, and make surescopeincludesopenid. For ping/push, check theclient_notification_tokenis present, at most 1024 characters, and a plain bearer token (no spaces or line breaks). If you send a signedrequestobject, make sure each request carries a freshjti— the same object presented twice is refused.invalid_binding_messageatbc-authorize. Thebinding_messageis over 140 displayable characters or contains control / line-break / bidirectional characters. Shorten it to plain text.invalid_client. Wrong credentials, or the client isn't confidential/CIBA-enabled. SPA clients cannot use CIBA.- Stuck on
authorization_pending. The user hasn't approved yet, or never got the email. Check the address resolves to an enrolled user and watchexpires_in(default 1800s). slow_downrepeatedly. You're polling faster thaninterval. Increase the wait between polls.access_denied. The user declined on their phone, or their email isn't permitted for this app by the group access gate.
Related
- CIBA — the concept and delivery modes.
- Tokens & sessions — the tokens you receive.
- Python (FastAPI) — the OIDC redirect flow, for comparison.
Machine-to-machine
Get an access token for the SenseCrypt Management API with the OAuth 2.0 client-credentials grant — register an M2M app, bind roles, call the token endpoint, and use the token.
Guides
Task-focused how-tos for building on SenseCrypt — managing tenants, customizing claims, RBAC, group-based access, branding, refresh tokens, key rotation, testing, and security hardening.