CIBA
Start a login from the back, and let the user approve on their own device
- Who's involved
- Backend, auth server, user's device
- Best for
- Approval on a separate device
- Difficulty
- Advanced
1Overview
How it works
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:
- The app names the user with a
login_hintand calls the backchannel endpoint. - The login server prompts the user on a device they trust, such as their phone.
- 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.
2Use cases
When would you use this?
Call centres
An agent asks the caller to approve the call on their own banking app, rather than reading out a PIN.
Payment approval
A shop till starts a payment and you confirm it on your phone.
Open banking
A finance app asks your bank to confirm a payment you requested.
Not the right fit? User is on the same device? Use Authorization Code with PKCE. It is simpler, because a redirect works.
3Interactive
Try it step by step
See it step by step
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.
4Practical advice
Do this, avoid that
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_hintsends a prompt to the wrong person - Logging
auth_req_idโ It identifies a pending login - Skipping
id_tokenvalidation โ Check its signature, issuer and audience - Ignoring
access_deniedโ The user may say no, so plan for it
5Code
Code example
Show the code
// 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, ... }
}