Client-Initiated Backchannel Authentication (CIBA)
CIBA is an authentication flow where the client starts a login, the user authenticates out-of-band (typically on their mobile device), and the client polls or is notified when the result is ready. There is no browser redirect and no front-channel interaction between the user and the client. Use CIBA for call centers, point-of-sale terminals, high-value transaction approvals, and IoT scenarios where the user is not sitting at the same device as the one driving the login.
In short: the client starts the login, the user approves it out-of-band (for example with a push notification to the LumoAuth app on their phone), and the client polls the token endpoint for the result. The parameters are the same as any other OAuth 2.0 grant; unfamiliar terms are in the glossary.
CIBA separates the device initiating authentication from the device where the user authenticates.
How CIBA Works
- Client sends an authentication request with a user hint (email, phone, etc.).
- Server returns an
auth_req_idimmediately. - User receives a notification on their device and approves or denies.
- Client polls the token endpoint for the result.
- On approval, tokens are issued.
- User to Consumption Device: Input User Identifier
- Consumption Device to Client: Start Login
- Client to Auth Server: POST /bc-authorize (login_hint)
- Auth Server to Client: Returns auth_req_id
- Auth Server to Auth Device: Push Notification / SMS
- Auth Device to User: Prompt for Approval
- User to Auth Device: Approves Request
- Auth Device to Auth Server: Auth Confirmation
- Client to Auth Server: POST /token (auth_req_id)
- Auth Server to Client: Tokens (access_token, id_token)
- Client to Consumption Device: Login Success
Token Delivery Mode
LumoAuth delivers tokens in poll mode: the client polls the token endpoint with the auth_req_id until the user has answered. It is the only mode advertised in discovery (backchannel_token_delivery_modes_supported: ["poll"]), so no callback endpoint is needed on your side.
Prerequisites
- The OAuth client must have CIBA enabled in its configuration.
- You need a way to identify the user via a hint (
login_hint,login_hint_token, orid_token_hint). - The user needs a way to approve: the LumoAuth app for push approval, or another factor the hosted approval page can challenge. See Push Authentication.
Backchannel Authentication Endpoint
POST /orgs/{orgId}/api/v1/oauth/bc-authorize
Initiates a backchannel authentication request.
Request Parameters
| Parameter | Required | Description |
|---|---|---|
scope | Yes | Space-separated scopes. openid is always included. |
login_hint | One hint required | User's email address or username |
id_token_hint | One hint required | Previously issued ID token for this user |
login_hint_token | One hint required | JWT containing user identification |
binding_message | No | Short message shown to the user during authentication (e.g., "Transaction: $50.00") |
requested_expiry | No | Preferred auth request lifetime in seconds (the server may use a shorter value) |
acr_values | No | Authentication level the approval must meet, as on the authorization endpoint |
user_code | Not supported | Discovery advertises backchannel_user_code_parameter_supported: false; sending one returns 400 invalid_user_code |
Provide exactly one of login_hint, login_hint_token, or id_token_hint. Including more than one results in an error.
Example Request
curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/bc-authorize \
-u "CLIENT_ID:CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "scope=openid%20profile%20email" \
-d "login_hint=user@example.com" \
-d "binding_message=Approve%20login%20from%20Call%20Center"
Success Response
{
"auth_req_id": "1c266114-a1be-4252-8ad1-04986c5b9ac1",
"expires_in": 120,
"interval": 5
}
| Field | Description |
|---|---|
auth_req_id | Unique identifier for this authentication request |
expires_in | Seconds until this request expires (configured per client; requested_expiry can shorten it) |
interval | Minimum seconds between poll requests |
LumoAuth never reveals whether the hint matched a user: an unknown user still receives an auth_req_id of the same shape, and polling it never returns tokens.
An AI agent can also start a CIBA request for one specific action by sending agent_id together with authorization_details (a structured description of what it wants to do, in the rich authorization request format). The approved details come back in the token response. See Approvals for the approval side.
Token Endpoint with CIBA Grant
POST /orgs/{orgId}/api/v1/oauth/token
In poll mode, the client polls this endpoint using the auth_req_id received from the backchannel authentication endpoint. Wait at least interval seconds between polls.
Request Parameters
| Parameter | Required | Description |
|---|---|---|
grant_type | Yes | urn:openid:params:grant-type:ciba |
auth_req_id | Yes | The auth_req_id from the backchannel authentication response |
Example Request
curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/token \
-u "CLIENT_ID:CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn%3Aopenid%3Aparams%3Agrant-type%3Aciba" \
-d "auth_req_id=1c266114-a1be-4252-8ad1-04986c5b9ac1"
Pending Response (Keep Polling)
{
"error": "authorization_pending",
"error_description": "The authorization request is still pending"
}
Success Response (User Approved)
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...",
"scope": "openid profile email",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Error Responses
CIBA defines specific error codes for different scenarios:
| Error | HTTP Status | Description |
|---|---|---|
authorization_pending | 400 | User hasn't approved or denied yet. Keep polling. |
slow_down | 400 | Polling too fast. Increase your interval. |
expired_token | 400 | The auth_req_id has expired. Start a new request. |
access_denied | 400 | User denied the authentication request. |
invalid_grant | 400 | The auth_req_id is invalid or was issued to a different client. |
The backchannel authentication endpoint itself answers:
| Error | HTTP Status | Description |
|---|---|---|
invalid_request | 400 | scope missing, no hint or more than one hint, or a malformed parameter |
invalid_scope | 400 | A requested scope is not allowed for this client |
unauthorized_client | 400 | CIBA is not enabled for this client (or not in the organization's plan) |
invalid_user_code | 400 | A user_code was sent; the parameter is not supported |
invalid_client | 401 | Client authentication failed, or the client is disabled |
too_many_requests | 429 | More than 60 requests per minute |
Example: Poll Mode Integration
A complete poll-mode implementation in Python:
import requests
import time
CLIENT_ID = "your_client_id"
CLIENT_SECRET = "your_client_secret"
BASE_URL = "https://app.lumoauth.dev/orgs/acme-corp/api/v1"
def authenticate_user(email):
# Step 1: Initiate backchannel authentication
auth_response = requests.post(
f"{BASE_URL}/oauth/bc-authorize",
auth=(CLIENT_ID, CLIENT_SECRET),
data={
"scope": "openid profile email",
"login_hint": email,
"binding_message": "Login requested from Support Portal"
}
)
if not auth_response.ok:
raise Exception(f"Auth failed: {auth_response.json()}")
auth_data = auth_response.json()
auth_req_id = auth_data["auth_req_id"]
interval = auth_data.get("interval", 5)
expires_in = auth_data["expires_in"]
print(f"Waiting for user to approve... (expires in {expires_in}s)")
# Step 2: Poll for tokens
start_time = time.time()
while time.time() - start_time < expires_in:
time.sleep(interval)
token_response = requests.post(
f"{BASE_URL}/oauth/token",
auth=(CLIENT_ID, CLIENT_SECRET),
data={
"grant_type": "urn:openid:params:grant-type:ciba",
"auth_req_id": auth_req_id
}
)
if token_response.ok:
print("User approved! Tokens received.")
return token_response.json()
error = token_response.json().get("error")
if error == "authorization_pending":
continue
elif error == "slow_down":
interval += 5
continue
elif error == "access_denied":
raise Exception("User denied the request")
elif error == "expired_token":
raise Exception("Request expired")
else:
raise Exception(f"Unexpected error: {token_response.json()}")
raise Exception("Timeout waiting for user approval")
# Usage
tokens = authenticate_user("john.doe@example.com")
Use Cases
- Call center authentication. A customer service agent initiates login verification on their terminal; the customer approves on their mobile device.
- Point-of-sale. A retail terminal initiates auth, and the customer approves on their phone.
- Transaction authorization. High-value transactions require out-of-band approval on a separate device.
Discovery Metadata
CIBA endpoints and capabilities are advertised in the OpenID Connect Discovery document:
curl https://app.lumoauth.dev/orgs/acme-corp/api/v1/.well-known/openid-configuration | jq '{
backchannel_authentication_endpoint,
backchannel_token_delivery_modes_supported,
backchannel_user_code_parameter_supported
}'
{
"backchannel_authentication_endpoint": "https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/bc-authorize",
"backchannel_token_delivery_modes_supported": ["poll"],
"backchannel_user_code_parameter_supported": false
}
Related
- Token endpoint
- Push Authentication — end-user flow for mobile approval
Standards used on this page
| Plain name | Standard |
|---|---|
| Client-initiated backchannel authentication (CIBA) | OpenID CIBA Core 1.0 |
| OAuth 2.0 (the core sign-in and token protocol) | RFC 6749 |
| Rich authorization requests | RFC 9396 |
| Discovery document | RFC 8414 and OpenID Connect Discovery |
See the standards reference for what each one covers and how LumoAuth uses it.