SenseCrypt Docs
Integrations

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 alerts the user by push and email (or your application alerts them), 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 face scan always completes on the user's phone through the signed device ceremony: the phone's hardware-bound key proves the device, and the live face check proves the person. This holds whatever login method the app uses for browser sign-ins. The passkey method's phishing resistance comes from WebAuthn origin binding in a browser, so it does not apply to CIBA, which has no browser.

Prerequisites

  • A SenseCrypt tenant.
  • A CIBA-enabled OIDC client registered in the admin console, configured for a delivery mode (poll, ping, or push — 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:

alt [not approved yet] [polling too fast] [approved] loop [poll ≥ interval s, until expires_in] POST /bc-authorizeclient creds · scope "openid …" · one hint 200 { auth_req_id, expires_in, interval } alert the user: push · emailed QR · or your own alert (universal link) GET /device/sessions/{id}/payload (signed) POST /device/sessions/{id}/complete (signed) POST /token · CIBA grant · auth_req_id · client creds error authorization_pending error slow_down 200 id_token + access_token (+ refresh_token) authenticate client · confirm CIBA delivery modeenforce scope grant · resolve hint → emailcreate pending session + auth_req_id open the request, run the face ceremonyface proof on device — no biometric data leaves the phone session → authorized (authenticated_at stamped) User's phone(Authenticator app) Your backend(consumption device) SenseCrypt IdP

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:

  • scope must include openid, and every scope must be one your client may use.
  • Supply exactly one user hint: login_hint (email), login_hint_token, or id_token_hint. The two token slots must carry an id_token this tenant issued to your application — anything else (an access token, or an id_token issued to another application, for instance) is unknown_user_id and no email is sent.
  • binding_message (optional) is shown to the user to confirm the request.
  • Include client_notification_token for ping/push delivery — a bearer token (RFC 6750 b64token: no spaces or line breaks) of at most 1024 characters; SenseCrypt presents it back to your endpoint as Authorization: Bearer …. An optional binding_message is capped at 140 displayable characters of plain text (invalid_binding_message otherwise). (A user_code is not supported — SenseCrypt is passwordless, so any user_code sent is ignored.)

The response:

{
  "auth_req_id": "…",
  "expires_in": 1800,
  "interval": 5
}
  • auth_req_id identifies this request at the token endpoint. It is single-use.
  • expires_in is how long you have to complete it (1800s by default, the same value whether or not the user is already enrolled). Send requested_expiry — a non-negative integer, clamped to the deployment's bounds — for a tighter window.
  • interval is the minimum seconds between polls (5s by default). For push delivery, interval is omitted (push clients don't poll).

SenseCrypt alerts the resolved user through your application's alert channels: a push and an emailed sign-in QR by default. They open the request in the Authenticator app, from the alert or from the app's list of waiting requests, and complete the face ceremony. To alert users yourself, see Delivering the alert yourself.

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:

ResponseMeaningAction
200 with tokensApproved.Done — auth_req_id is single-use.
authorization_pendingNot approved yet.Keep polling at interval. A DPoP-bound client takes the DPoP-Nonce this answer carries into its next proof.
slow_downPolling too fast.Back off, then keep polling. Carries a DPoP-Nonce for a DPoP-bound client too.
expired_tokenThe request expired.Start over.
access_deniedThe 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 / …
        # This sample sends no DPoP proof. A DPoP-bound client reads
        # resp.headers["DPoP-Nonce"] here and puts it in its next proof.
        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 (with Authorization: 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 answers access_denied / expired_token there).
  • 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 no interval). 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

  1. Call bc-authorize with a test user's email and confirm you get an auth_req_id.
  2. Confirm the user receives the sign-in email and can approve on their phone.
  3. While pending, confirm your poll returns authorization_pending; after approval, confirm it returns 200 with an id_token.
  4. Validate the id_token (your application's registered algorithm — ES256 by default, RS256 if you chose it — plus iss, aud, exp) — the same as any sign-in, minus the nonce check.

Troubleshooting

  • invalid_request at bc-authorize. Usually a missing/duplicate hint — send exactly one of login_hint / login_hint_token / id_token_hint, and make sure scope includes openid. For ping/push, check the client_notification_token is present, at most 1024 characters, and a plain bearer token (no spaces or line breaks). If you send a signed request object, make sure each request carries a fresh jti — the same object presented twice is refused.
  • unknown_user_id with a login_hint_token or id_token_hint. The token must be an id_token SenseCrypt issued to this application. An access token, or an id_token issued to another application in your tenant, is refused this way. If you do not hold one, send the user's email as login_hint instead.
  • invalid_binding_message at bc-authorize. The binding_message is 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 watch expires_in (default 1800s).
  • slow_down repeatedly. You're polling faster than interval. 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.

On this page