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
- The client calls
POST /oauth2/bc-authorizewith its credentials, the scopes it wants, and alogin_hintnaming the user. It receives anauth_req_idand a pollinginterval. - Your server reaches the user through your
sendNotificationcallback, by email or push notification, with a link to the provider. - The user opens the link, signs in if needed, and approves or denies on your consent page. They land on your
resultPage. - The client polls
POST /oauth2/tokenwith theauth_req_id. It receivesauthorization_pendinguntil the user decides, then the tokens oraccess_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/cibaAdd 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.
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.
Show the binding message on your consent page
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.
const params = new URLSearchParams(location.search);
const bindingMessage = params.get("binding_message"); // only for CIBA requestsAccept 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.
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:
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.
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:
- A signed-out user goes to
loginPagefirst. Nothing about the request is shown before sign-in. postLoginruns as it does for an authorization request, and tokens carry thereferenceIdthatconsentReferenceIdreturns.consentPagealways opens, even for a client withskip_consentor a stored consent, because the user must confirm each request. The user can narrow the scopes there.- The decision is recorded only for the user the
login_hintnamed. Anyone else who opens the link, signed in as another account, lands onresultPagewith 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:
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:
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".
| Error | Meaning |
|---|---|
invalid_request | The request is unknown, already decided, expired, or for another user, or acr is not in acrValuesSupported. |
invalid_scope | scopes 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.
| Parameter | Required | Description |
|---|---|---|
scope | yes | Space-delimited scopes. Must include openid. |
login_hint | yes | Identifies the user, by email unless you set resolveUser. |
client_notification_token | ping only | An RFC 6750 bearer token of at most 1024 characters, sent back on the ping callback. |
binding_message | no | Up to 64 characters of plain text, in any script, shown in the notification, on the consent page, and by the client. |
requested_expiry | no | Requested lifetime in seconds, a positive integer. Capped at expiresIn. |
resource | no | One 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:
| Error | Meaning |
|---|---|
invalid_request | A 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_scope | scope does not include openid, the client may not request it, or a requested resource does not allow openid. |
invalid_client | The client did not authenticate, or is public and allowPublicClients is off. |
unauthorized_client | The client is not registered for the CIBA grant with a delivery mode this server supports. |
invalid_binding_message | The binding message is longer than 64 characters or contains control or invisible formatting characters. |
unknown_user_id | No user matches the login_hint. |
invalid_target | A resource is malformed or unknown. |
The token poll returns:
| Error | Meaning |
|---|---|
authorization_pending | The user has not decided yet. Keep polling. |
slow_down | Polling faster than interval. Wait at least 5 more seconds between polls. |
access_denied | The user denied the request. |
expired_token | The request expired before the user decided. Once a later request has cleared it, the poll returns invalid_grant instead. |
invalid_grant | The auth_req_id is unknown, belongs to another client, or was already used. |
invalid_target | The poll's resource was not approved for this request. The request stays approved. |
invalid_scope | The 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:
| Field | Type | Description |
|---|---|---|
authReqId | string | The auth_req_id, hashed like the provider's tokens. The raw value is never stored. |
clientId | string | The client that started the request. |
userId | string | The user the login_hint named, the only user who can decide. |
scope | string | The requested scopes, narrowed to the approved ones on approval. |
bindingMessage | string | The optional binding message. |
resources | string[] | The optional resource indicators. |
status | string | pending, 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. |
clientNotificationEndpoint | string | Ping mode only: the client's notification endpoint when the request started. |
encryptedClientNotificationToken | string | Ping mode only: the client's client_notification_token, encrypted with the server secret. |
encryptedAuthReqId | string | Ping mode only: the auth_req_id, encrypted with the server secret, because the callback carries the raw value. |
referenceId | string | The consent reference recorded on approval, such as an organization. |
authTime | date | Creation time of the approving session, or the authTime passed to completeCibaRequest, issued as auth_time. |
acr | string | The acr the approval established, issued in the ID token. |
amr | string[] | The amr the approval established, issued in the ID token. |
lastPolledAt | date | When the client last polled. |
expiresAt | date | When the request expires. |
createdAt | date | When 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.
completeCibaRequestalso 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_idtells 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, resolvelogin_hintonly 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_messageand 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
httpson a public host, with no credentials or fragment, the same rules asbackchannel_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_idis stored hashed.
Current limitations
- Push delivery is not supported.
- Only
login_hintis supported;id_token_hintandlogin_hint_tokenare rejected withinvalid_request. user_code, signed authentication requests,acr_values, andauthorization_detailsare not supported.acr_valuesare ignored.- There is no API that lists a user's pending requests. To show them in your app, keep the
requestIds thatsendNotificationreceives. - There is no per-user limit on requests; the limit applies per IP address.