โ† All flows

CIBA

Start a login from the back, and let the user approve on their own device

CIBA (Client-Initiated Backchannel Authentication) lets an app ask the login server to authenticate a specific user, with no redirect and no browser. It is part of the OpenID Connect family. It works in three moves:

  1. The app names the user with a login_hint and calls the backchannel endpoint.
  2. The login server prompts the user on a device they trust, such as their phone.
  3. The app gets the tokens by polling, or the server notifies it (poll, ping and push are the three delivery modes). This page shows poll.

See it step by step

๐Ÿง‘
You
๐Ÿ–ฅ๏ธ
App
๐Ÿ”
Login server
Who + why

Step 1 of 6

The app asks to sign someone in

It calls the backchannel endpoint (/bc-authorize) with a login_hint naming the user and a binding_message to show them.

When to use this flow

  • Call centres and shop counters โ€” Confirm a customer without them typing anything into your system
  • Payment approval โ€” Ask the account holder to approve a specific action
  • Open banking โ€” Decoupled consent is common in this space
  • The user is on the device with the app โ€” Authorization Code with PKCE is simpler

Good practices

  • Show a binding_message โ€” Let the user match the prompt to the real session
  • Respect interval โ€” Poll no faster than the server says
  • Stop at expires_in โ€” Start a new request instead of polling forever
  • Authenticate the app strongly โ€” Use a secret or a signed JWT

Common mistakes

  • Guessing the user โ€” A wrong login_hint sends a prompt to the wrong person
  • Logging auth_req_id โ€” It identifies a pending login
  • Skipping id_token validation โ€” Check its signature, issuer and audience
  • Ignoring access_denied โ€” The user may say no, so plan for it
Code example
CIBA poll mode example
javascript
// CIBA, poll mode
const start = await fetch('https://auth.provider.com/bc-authorize', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded',
    Authorization: 'Basic ' + btoa(CLIENT_ID + ':' + CLIENT_SECRET),
  },
  body: new URLSearchParams({
    scope: 'openid payments',
    login_hint: 'user@example.com',
    binding_message: 'W4SJ',
  }),
}).then((r) => r.json());

let interval = start.interval ?? 5;
while (true) {
  await new Promise((r) => setTimeout(r, interval * 1000));
  const res = await fetch('https://auth.provider.com/oauth/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Authorization: 'Basic ' + btoa(CLIENT_ID + ':' + CLIENT_SECRET),
    },
    body: new URLSearchParams({
      grant_type: 'urn:openid:params:grant-type:ciba',
      auth_req_id: start.auth_req_id,
    }),
  });
  const body = await res.json();
  if (body.error === 'authorization_pending') continue;
  if (body.error === 'slow_down') { interval += 5; continue; }
  if (body.error) throw new Error(body.error); // access_denied or expired_token
  return body; // { access_token, id_token, ... }
}