CIBA: OpenID Connect backchannel authentication for decoupled flows

Standard OAuth 2.0 authorization flows assume the user is present at a browser on the same device as the client. The user visits the authorization endpoint, authenticates, and the browser redirects back to the client with a code. This model breaks down for a class of scenarios that are increasingly common: a call center agent who needs to authenticate a customer on the phone, a smart TV app where the remote control interface cannot render a browser, or a banking app where the authorization decision must happen on the customer's phone while the transaction originates at an ATM. CIBA — Client-Initiated Backchannel Authentication — is the OIDC specification designed for these decoupled flows.

The CIBA model

In CIBA, the client (the "consumption device" — the TV, the ATM, the call center CRM) sends an authentication request directly to the authorization server's backchannel endpoint, without a browser redirect. The authorization server then pushes an authentication challenge to the user's "authentication device" (typically their phone) out of band. The user approves on their phone, and the authorization server delivers tokens to the client through one of three delivery modes.

The backchannel authentication request includes a way to identify the user — this can be a login hint, an ID token hint from a prior session, or a binding message that the user can see on both devices to confirm they are the same transaction.

// CIBA backchannel authentication request
POST /bc-authorize HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <client_credentials>

scope=openid+profile+payments:authorize
&login_hint=user@example.com
&binding_message=TXN-7842
&requested_expiry=300
&request_context={"transaction_amount":"250.00","currency":"USD","merchant":"Coffee Shop"}

The binding_message is displayed on both the consumption device and the authentication device so the user can confirm they are authorizing the same transaction. In financial services, this is critical for preventing authorization confusion attacks.

// Authorization server response — auth request accepted
{
  "auth_req_id": "1c266114-a1be-4252-8ad1-04986c5b9ac1",
  "expires_in": 300,
  "interval": 5
}

Poll mode

In poll mode, the client repeatedly polls the token endpoint until the user completes the authentication or the request expires. The client uses the auth_req_id from the initial response as the grant identifier.

// Poll mode — client polls at the interval specified in the auth response
async function pollForTokens(authReqId: string, interval: number): Promise<Tokens> {
  while (true) {
    await sleep(interval * 1000);

    const response = await fetch('https://auth.example.com/oauth/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'urn:openid:params:grant-type:ciba',
        auth_req_id: authReqId,
        client_id: process.env.CLIENT_ID,
        client_secret: process.env.CLIENT_SECRET
      })
    });

    const data = await response.json();

    if (response.ok) {
      return data;  // tokens issued
    }

    if (data.error === 'authorization_pending') {
      continue;  // user hasn't acted yet
    }

    if (data.error === 'slow_down') {
      interval += 5;  // back off as directed
      continue;
    }

    if (data.error === 'access_denied') {
      throw new Error('User denied the authorization request');
    }

    if (data.error === 'expired_token') {
      throw new Error('Authentication request expired');
    }

    throw new Error(`Unexpected error: ${data.error}`);
  }
}

Ping mode

In ping mode, the authorization server sends a notification to the client's pre-registered notification endpoint when the authentication is complete. The client then retrieves the tokens with a single token request. This avoids the polling overhead and is better for server-side clients that can receive webhook-style callbacks.

// Client notification endpoint — receives ping from auth server
app.post('/ciba/notification', async (req, res) => {
  // Validate the notification JWT
  const notificationJwt = req.headers.authorization?.replace('Bearer ', '');
  const payload = await verifyNotificationJwt(notificationJwt);

  const authReqId = payload.auth_req_id;

  // Fetch the tokens now that authentication is complete
  const tokens = await fetchTokensForAuthReq(authReqId);

  // Process the authorized transaction
  await processTransaction(tokens.access_token, tokens.id_token);

  res.status(204).end();
});

Push mode

Push mode is the most aggressive: the authorization server pushes the actual tokens directly to the client's notification endpoint, rather than just a ping. The client receives tokens without making any additional request. This minimizes latency and round trips but requires the notification endpoint to accept and securely store incoming tokens.

Push mode trades simplicity for security risk. The tokens are sent over HTTPS to your callback endpoint. If that endpoint is compromised or logs request bodies, tokens are exposed. Ping mode, where you retrieve tokens via an authenticated server-to-server call, is generally safer for high-value flows.

CIBA in financial-grade API (FAPI 2.0)

CIBA is a required component of FAPI 2.0 — the security profile used by open banking implementations in the UK, Australia, Brazil, and other jurisdictions. FAPI adds several constraints on top of the base CIBA spec:

  • The client authentication must use private_key_jwt — no client secrets. The client signs a JWT with its registered private key.
  • The backchannel authentication request must be a signed JWT (JAR — JWT-Secured Authorization Request).
  • The binding message must be present and visible to the user.
  • The authorization server must display the requested transaction details to the user on their authentication device before they can approve.
// FAPI 2.0 CIBA request with signed JAR and private_key_jwt client auth
// 1. Create the signed backchannel auth request JWT
const authRequestJwt = await new SignJWT({
  iss: CLIENT_ID,
  aud: 'https://auth.example.com',
  iat: Math.floor(Date.now() / 1000),
  exp: Math.floor(Date.now() / 1000) + 60,
  scope: 'openid payments:authorize',
  login_hint: 'user@example.com',
  binding_message: 'TXN-7842',
  request_context: { amount: '250.00', currency: 'GBP' }
})
.setProtectedHeader({ alg: 'PS256', kid: CLIENT_KEY_ID })
.sign(clientPrivateKey);

// 2. Create private_key_jwt for client authentication
const clientAssertionJwt = await new SignJWT({
  iss: CLIENT_ID,
  sub: CLIENT_ID,
  aud: 'https://auth.example.com/bc-authorize',
  jti: crypto.randomUUID(),
  iat: Math.floor(Date.now() / 1000),
  exp: Math.floor(Date.now() / 1000) + 60
})
.setProtectedHeader({ alg: 'PS256', kid: CLIENT_KEY_ID })
.sign(clientPrivateKey);

// 3. Submit to backchannel endpoint
const response = await fetch('https://auth.example.com/bc-authorize', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    request: authRequestJwt,
    client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
    client_assertion: clientAssertionJwt
  })
});

CIBA is not a common pattern in general-purpose web applications, but if you are building anything in the financial services, healthcare, or telecommunications space where authorization decisions must be made across device boundaries, it is the specification-compliant solution to reach for rather than inventing your own out-of-band notification mechanism.

← Back to blog Try Bastionary free →