You are currently viewing documentation for v1.8 (Beta)

Client-Initiated Backchannel Authentication (CIBA)

Let a client ask a known user to approve access on their own device, with no redirect from the client.

OAuth2 OIDC

Client-Initiated Backchannel Authentication (CIBA) is an OpenID Connect flow for decoupled authentication: the client that wants tokens is not where the user signs in. The client names the user, your server sends that user a link, the user approves on their own phone or laptop, and the client then collects tokens. The client never redirects the user and never sees their credentials.

Use it when the client already knows who it is acting for but cannot show that person a browser:

  • An AI agent or backend job that needs a person's approval before it acts for them.
  • A call-center or support tool where the operator starts a sign-in the customer approves on their phone.
  • A kiosk or terminal that should never collect the user's credentials.

If the client can show a code or a QR code to a person in front of it, use the device authorization grant instead.

The plugin extends @better-auth/oauth-provider and requires oauthProvider() and jwt(). It supports the CIBA poll and ping delivery modes.

How the flow works

  1. The client calls POST /oauth2/bc-authorize with its credentials, the scopes it wants, and a login_hint naming the user. It receives an auth_req_id and a polling interval.
  2. Your server reaches the user through your sendNotification callback, by email or push notification, with a link to the provider.
  3. The user opens the link, signs in if needed, and approves or denies on your consent page. They land on your resultPage.
  4. The client polls POST /oauth2/token with the auth_req_id. It receives authorization_pending until the user decides, then the tokens or access_denied. A client registered for ping mode is also called back when the user decides, so it can wait for that instead of polling.

Installation

Install the plugin

npm install @better-auth/ciba

Add the plugin to the server

Add ciba() after oauthProvider() and jwt(). sendNotification sends the user the link, and resultPage is the page they land on after deciding.

auth.ts
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";
import { oauthProvider } from "@better-auth/oauth-provider";
import { ciba } from "@better-auth/ciba"; 

export const auth = betterAuth({
  plugins: [
    jwt(),
    oauthProvider({
      loginPage: "/login",
      consentPage: "/consent",
      scopes: ["openid", "profile", "email"],
    }),
    ciba({ 
      resultPage: "/ciba", 
      sendNotification: async ({ user, clientName, bindingMessage, url }) => { 
        await sendEmail({ 
          to: user.email, 
          subject: `${clientName ?? "An application"} wants to act for you`, 
          text: `${bindingMessage ?? ""}\nReview the request: ${url}`, 
        }); 
      }, 
    }), 
  ],
});

Migrate the database

The plugin adds a cibaRequest model. Run npx auth@latest migrate with the built-in Kysely adapter, or run npx auth@latest generate and apply the schema with your ORM.

The link opens your existing consentPage. For a CIBA request, its query carries ciba_request_id, client_id, scope, and the client's binding_message when it sent one. Show the binding message as plain text next to the client and scopes, so the user can check that it matches what the client displays.

consent-page.ts
const params = new URLSearchParams(location.search);
const bindingMessage = params.get("binding_message"); // only for CIBA requests

Accept or deny with oauth2.consent as you already do for authorization requests.

Build the result page

After the user decides, they land on resultPage with status=approved or status=denied. When the link is invalid, expired, already used, or for another account, they land there with error and error_description instead. Tell them they can return to the app that asked.

Register a client

A CIBA client is registered for the CIBA grant type and declares its backchannel_token_delivery_mode (CIBA §4), and in ping mode its backchannel_client_notification_endpoint. Dynamic registration and Client ID Metadata Documents accept both as top-level fields, and admin-created clients put them in metadata. Registration rejects a mode not in deliveryModes, and a ping endpoint that is not absolute https on a public host, with invalid_client_metadata.

register-client.ts
import { CIBA_GRANT_TYPE } from "@better-auth/ciba";

const client = await auth.api.adminCreateOAuthClient({
  headers, // an authenticated admin session
  body: {
    client_name: "Support Agent",
    grant_types: [CIBA_GRANT_TYPE], // "urn:openid:params:grant-type:ciba"
    metadata: { backchannel_token_delivery_mode: "poll" },
  },
});
// Store client.client_id and client.client_secret for the agent.

The client must authenticate (CIBA §7.1). Agents and CLIs that cannot keep a secret can register with token_endpoint_auth_method: "none" when you set allowPublicClients; they send client_id in the form body of both /oauth2/bc-authorize and /oauth2/token instead, and should bind their tokens with DPoP.

If anyone can register a client, any client can name any user. Set resolveUser to accept only identifiers the requesting client was already given, such as the sub from its earlier ID tokens, so a client cannot prompt or look up users it does not know:

auth.ts
ciba({
  resultPage: "/ciba",
  sendNotification,
  resolveUser: async ({ loginHint, client }) => {
    // Your lookup: map the subject this client received back to a user.
    return findUserBySubjectForClient(loginHint, client.clientId);
  },
});

Request tokens from the client

The client starts a request, shows its binding_message to whoever is watching, then polls the token endpoint. Both calls are form-encoded and authenticate the client; this example uses HTTP Basic.

agent.ts
const basic = btoa(`${clientId}:${clientSecret}`);
const form = (body: Record<string, string>) => ({
  method: "POST",
  headers: {
    authorization: `Basic ${basic}`,
    "content-type": "application/x-www-form-urlencoded",
  },
  body: new URLSearchParams(body),
});

// 1. Start the request.
const request = await fetch(
  "https://auth.example.com/api/auth/oauth2/bc-authorize",
  form({
    scope: "openid profile",
    login_hint: "ada@example.com",
    binding_message: "Code 4821", // show this in the client too
  }),
).then((r) => r.json());
// => { auth_req_id, expires_in, interval }

// 2. Poll until the user decides.
let interval = request.interval;
for (;;) {
  await new Promise((resolve) => setTimeout(resolve, interval * 1000));
  const body = await fetch(
    "https://auth.example.com/api/auth/oauth2/token",
    form({
      grant_type: "urn:openid:params:grant-type:ciba",
      auth_req_id: request.auth_req_id,
    }),
  ).then((r) => r.json());
  if (body.error === "authorization_pending") continue;
  if (body.error === "slow_down") {
    interval += 5; // CIBA §11: wait at least 5 more seconds from now on
    continue;
  }
  if (body.error) throw new Error(body.error); // access_denied, expired_token, ...
  return body; // { access_token, token_type, expires_in, id_token, ... }
}

Ping mode

Enable ping with deliveryModes: ["poll", "ping"]. A ping client registers backchannel_token_delivery_mode: "ping" and the backchannel_client_notification_endpoint the server calls, and sends a client_notification_token with each backchannel request. When the user approves or denies, the server POSTs { "auth_req_id": "..." } to the endpoint registered when the request started with Authorization: Bearer <client_notification_token>, and the client then calls the token endpoint once. The server makes one attempt with a 5-second timeout and does not follow redirects. If the callback fails, the client can still poll.

To get a JWT access token for an API, send its RFC 8707 resource with the request, as with other grants. A DPoP proof on the token poll binds the token to the client's key.

What the user sees

The link points to /oauth2/authorize?ciba_request_id=<id>, so the request runs through the provider's own pages:

  1. A signed-out user goes to loginPage first. Nothing about the request is shown before sign-in.
  2. postLogin runs as it does for an authorization request, and tokens carry the referenceId that consentReferenceId returns.
  3. consentPage always opens, even for a client with skip_consent or a stored consent, because the user must confirm each request. The user can narrow the scopes there.
  4. The decision is recorded only for the user the login_hint named. Anyone else who opens the link, signed in as another account, lands on resultPage with an error, and the request stays pending for the right user.

A denial is final: the client's next poll returns access_denied, and the link stops working. The ID token's auth_time is the creation time of the approving user's session, and its acr and amr are what the provider's authenticationContext.resolve returned for that session.

Decide from your own app

When your own app can reach the user, such as a mobile app that shows a push notification with Approve and Deny buttons, record the decision from your server with auth.api.completeCibaRequest instead of sending the user to the link. sendNotification receives the requestId to send to the app:

auth.ts
ciba({
  resultPage: "/ciba",
  sendNotification: async ({ requestId, user, clientId, clientName, scopes, bindingMessage }) => {
    // Always show who is asking; not every client has a name.
    await push.send(user.id, { requestId, client: clientName ?? clientId, scopes, bindingMessage });
  },
});

When the app reports the user's answer, your server states who decided and how they proved it:

push-decision.ts
await auth.api.completeCibaRequest({
  body: {
    requestId,
    userId: appUser.id, // the user your app authenticated
    decision: approved ? "approved" : "denied",
    // Approval only. Each is optional.
    scopes: ["openid", "profile"], // a subset of the requested scopes
    authTime: new Date(), // issued as auth_time
    acr: "urn:example:acr:biometric", // must be in acrValuesSupported
    amr: ["face"],
  },
});

The provider records the decision only when userId is the user the login_hint named and the request is still pending; otherwise it returns invalid_request and changes nothing. Without acr, the ID token claims "0", so state an acr when "0" is not in acrValuesSupported. The approved scopes must be among the requested ones and include openid. A ping client is called back as it is after a decision from the link. This call skips the provider's sign-in, post-login, and consent pages, so your app is responsible for:

  • authenticating the user before it reports a decision, and
  • showing the client, the scopes, and the binding message before the user approves.

The ID token carries only the auth_time, acr, and amr you pass; without acr it reports "0".

ErrorMeaning
invalid_requestThe request is unknown, already decided, expired, or for another user, or acr is not in acrValuesSupported.
invalid_scopescopes includes a scope the client did not request, or leaves out openid. The request stays pending.

Endpoints

POST /oauth2/bc-authorize

Starts a request. It accepts only application/x-www-form-urlencoded (CIBA §7.1), authenticates the client, and returns auth_req_id, expires_in, and interval. Each IP address can start 10 requests per minute.

ParameterRequiredDescription
scopeyesSpace-delimited scopes. Must include openid.
login_hintyesIdentifies the user, by email unless you set resolveUser.
client_notification_tokenping onlyAn RFC 6750 bearer token of at most 1024 characters, sent back on the ping callback.
binding_messagenoUp to 64 characters of plain text, in any script, shown in the notification, on the consent page, and by the client.
requested_expirynoRequested lifetime in seconds, a positive integer. Capped at expiresIn.
resourcenoOne or more resource indicators the token is bound to.

POST /oauth2/token

The provider's token endpoint. With grant_type=urn:openid:params:grant-type:ciba and the auth_req_id, it returns the tokens once the user approves, or one of the errors below. Each auth_req_id issues tokens once. The poll may send resource to narrow the token to some of the approved resources (RFC 8707 §2.2).

Options

Prop

Type

Errors

The backchannel endpoint returns these CIBA §13 errors:

ErrorMeaning
invalid_requestA required parameter is missing or malformed, the request uses a hint other than login_hint, or a ping client sent no valid client_notification_token.
invalid_scopescope does not include openid, the client may not request it, or a requested resource does not allow openid.
invalid_clientThe client did not authenticate, or is public and allowPublicClients is off.
unauthorized_clientThe client is not registered for the CIBA grant with a delivery mode this server supports.
invalid_binding_messageThe binding message is longer than 64 characters or contains control or invisible formatting characters.
unknown_user_idNo user matches the login_hint.
invalid_targetA resource is malformed or unknown.

The token poll returns:

ErrorMeaning
authorization_pendingThe user has not decided yet. Keep polling.
slow_downPolling faster than interval. Wait at least 5 more seconds between polls.
access_deniedThe user denied the request.
expired_tokenThe request expired before the user decided. Once a later request has cleared it, the poll returns invalid_grant instead.
invalid_grantThe auth_req_id is unknown, belongs to another client, or was already used.
invalid_targetThe poll's resource was not approved for this request. The request stays approved.
invalid_scopeThe client no longer allows an approved scope, or a requested resource no longer allows openid. The request stays approved.

Schema

The plugin adds one model, cibaRequest:

FieldTypeDescription
authReqIdstringThe auth_req_id, hashed like the provider's tokens. The raw value is never stored.
clientIdstringThe client that started the request.
userIdstringThe user the login_hint named, the only user who can decide.
scopestringThe requested scopes, narrowed to the approved ones on approval.
bindingMessagestringThe optional binding message.
resourcesstring[]The optional resource indicators.
statusstringpending, approved, or denied. A request is deleted when tokens are issued, when the client learns of a denial or expiry, or when a later request finds it expired.
clientNotificationEndpointstringPing mode only: the client's notification endpoint when the request started.
encryptedClientNotificationTokenstringPing mode only: the client's client_notification_token, encrypted with the server secret.
encryptedAuthReqIdstringPing mode only: the auth_req_id, encrypted with the server secret, because the callback carries the raw value.
referenceIdstringThe consent reference recorded on approval, such as an organization.
authTimedateCreation time of the approving session, or the authTime passed to completeCibaRequest, issued as auth_time.
acrstringThe acr the approval established, issued in the ID token.
amrstring[]The amr the approval established, issued in the ID token.
lastPolledAtdateWhen the client last polled.
expiresAtdateWhen the request expires.
createdAtdateWhen the request was created.

Security

  • Only the named user can decide. Approval from the link always shows consent, so a user who receives a request they did not expect can deny it, and someone else who gets hold of the link cannot approve or deny it. completeCibaRequest also refuses a decision for any other user, but it skips consent: it trusts your server to have authenticated that user and shown them the request.
  • Limit who a client can name. Each request notifies a person, and unknown_user_id tells the client whether a hint matched a user, as CIBA §13 specifies. Each IP address can start 10 requests per minute. When anyone can register a client, resolve login_hint only to users the client already knows, as shown in Register a client.
  • The binding message ties the two devices together. Ask clients to send a short, random binding_message and to display it, so the user can tell their request apart from one someone else started at the same moment. Render it as text.
  • The ping callback only reaches safe endpoints. Registration and each request check that the notification endpoint is absolute https on a public host, with no credentials or fragment, the same rules as backchannel_logout_uri. The check does not resolve DNS, so restrict outbound network access where you can.
  • Tokens are issued once. An approved request is claimed and deleted in one atomic step, so of two concurrent polls only one receives tokens. The auth_req_id is stored hashed.

Current limitations

  • Push delivery is not supported.
  • Only login_hint is supported; id_token_hint and login_hint_token are rejected with invalid_request.
  • user_code, signed authentication requests, acr_values, and authorization_details are not supported. acr_values are ignored.
  • There is no API that lists a user's pending requests. To show them in your app, keep the requestIds that sendNotification receives.
  • There is no per-user limit on requests; the limit applies per IP address.