CIBA
Security: CIBA
CIBA (Cbox\Id\OAuthServer\ — BackchannelAuthentication) issues tokens on the
strength of an out-of-band human approval, so its controls are load-bearing. The
polling state machine is the device-authorization grant's, and inherits its
hardening.
Controls
| Control | Mechanism | Where |
|---|---|---|
| Hashed at rest | auth_req_id stored only as SHA-256; looked up by hash + client id |
CibaAuthenticationService, ciba_requests.auth_req_id_hash (unique) |
| Single-use | approved → redeemed flip under lockForUpdate in a transaction |
CibaAuthenticationService::redeem() |
| TTL | short approval window; requested_expiry clamped to the configured ceiling |
config oauth.ciba.ttl_seconds |
| Poll throttle | polling faster than interval → slow_down, clock not advanced |
CibaAuthenticationService::redeem(), config oauth.ciba.poll_interval |
| Split identifiers | client's auth_req_id ≠ host's internal approval id |
BackchannelAuthenticationResult |
| Deny-by-default | unresolvable login_hint never creates a request (unknown_user_id); an email hint must be one the person confirmed, and an unconfirmed one answers exactly like an unknown one |
CibaAuthenticationService::request() |
| Bound audience | RFC 8707 resource on the backchannel request is checked before anyone is prompted, stored with the request, and the poll may repeat it but never name another (invalid_target) |
BackchannelAuthenticationController, TokenController::ciba(), ciba_requests.resource |
| Prompt flooding | per client + login_hint (5 per 60 s) and per person across every client (10 per 600 s) → 429 slow_down with Retry-After; nothing is filed, nobody is prompted |
config oauth.ciba.rate_limits.* |
| Audit | oauth.ciba.requested (actor: the client), oauth.ciba.approved / oauth.ciba.denied (actor: the person) |
CibaAuthenticationService |
| Bound approval | a CIBA token carries auth_req_id (the client's own, raw: spent at that point) and binding_message (exactly what the person saw), on the access token and the ID Token; read from the locked row; reserved, so no TokenMinting hook can forge them; never on a refresh |
BackchannelBinding, CibaAuthenticationService::redeem(), JwtTokenIssuer |
| Ping delivery | operator-registered https endpoint only; client_notification_token required (≤ 1024 chars); token and raw auth_req_id encrypted at rest and cleared once delivered or redeemed; SSRF guard with pinned DNS, no redirects; queued with short timeouts and retries |
QueuedCibaClientNotifications, Jobs\NotifyCibaClient, config oauth.ciba.ping.* |
| Environment scope | BelongsToEnvironment — cross-env invisible |
Models\BackchannelAuthRequest |
Self-registered agents and CLIs
A client that registered itself (RFC 7591, mcp mode) may use CIBA, and the device
grant, only where a protected resource opened them to such clients:
'protected_resources' => [[
'identifier' => 'https://app.example.com/mcp',
'scopes' => ['mcp:read', 'mcp:write'],
'dynamic_clients' => true,
'dynamic_client_grants' => ['ciba', 'device_code'],
// 'dynamic_public_backchannel' => true, // see below
]],
Such a client is anyone who reached the registration endpoint, so every request is
checked again, against the one resource it names (DynamicGrantPolicy):
resourceis required and must be a resource that opened the grant (invalid_targetotherwise);- a
binding_messageis required (invalid_binding_message). The client shows it in its own window, the person sees the same words beside Approve; a short code at its end is the convention; - the rate limits above apply as to every client.
Register with your own key. The recommended shape is
token_endpoint_auth_method: "private_key_jwt" with an inline jwks: the server hands
out no secret, each install proves possession of a key only it holds, and the client is
confidential — CIBA's usual requirement.
Public clients, deliberately. CIBA normally refuses public clients: a client_id is
not a secret, and without client authentication anyone could spray prompts at
login_hints. A resource may still allow it with dynamic_public_backchannel, for a CLI
that cannot keep a key. That is acceptable here because what makes the flow safe does not
depend on who the client is: the person sees the client's name, the resource, the scopes
and the binding message, and the request is rate limited per client, per hint and per
person. What a public client loses is attribution — two installs of the same CLI are
indistinguishable — so prefer private_key_jwt wherever a key can be kept.
backchannel_user_code_parameter is not supported, and is advertised as false.
CIBA's user_code is a secret the person sets up with the OP beforehand and hands to the
client; it is not a code the client shows. The matching code a CLI shows belongs in the
binding_message.
The two-identifier split
CIBA has two parties who must not be conflated:
- The client (the agent) holds
auth_req_id— its polling secret, returned by the backchannel endpoint and presented at the token endpoint. - The host's approval surface holds the internal request id — the handle to
approve()/deny(), delivered only via theoauth.backchannel_authentication_requesteddomain event.
The backchannel endpoint returns only auth_req_id (never the internal id), so
a client can never approve its own request. Keeping them separate is the structural
guard behind the whole flow.
Bound tokens
A resource server that asked a person to approve ONE thing (a deployment plan) must be able to tell that a token is that approval, not another one the same client holds. A token minted from a CIBA request carries:
| Claim | Value |
|---|---|
auth_req_id |
the client's own auth_req_id for the request |
binding_message |
the binding message the person saw; absent when none was sent |
Both are on the access token and, when openid was granted, the ID Token. A client that
put a digest (or a prefix of one) in the binding message, and stored its auth_req_id,
can check both. The auth_req_id is the raw value: redemption is single-use, so by the
time a token carries it, it opens nothing. A refresh does not carry the binding on.
The binding reaches the access token through AuthenticationEvent and
AuthenticationAwareTokenIssuer; a host's own issuer that does not implement that
contract mints tokens without it.
Ping mode
Poll is the default and every existing client stays on it. An operator registers ping for a client:
app(ClientRegistry::class)->configureBackchannelTokenDelivery(
$client, BackchannelTokenDeliveryMode::Ping, 'https://app.example.com/webhooks/cbox-id/ciba',
);
The backchannel request must then carry client_notification_token (invalid_request
otherwise, before anyone is prompted). When the person approves or denies, a queued job
POSTs to the endpoint:
POST /webhooks/cbox-id/ciba
Authorization: Bearer <client_notification_token>
Content-Type: application/json
{"auth_req_id": "auth_req_…"}
Any 2xx is delivered (oauth.ciba.notified). 5xx, 408, 429 and connection failures are
retried (2 s, 5 s, 15 s, 30 s; oauth.ciba.ping.max_attempts, default 5); anything else,
or the last failure, is oauth.ciba.notification_failed. The client then calls the token
endpoint once with the auth_req_id, and may still poll if a ping never arrives.
Dynamic registration cannot set ping.
Single-use under concurrency
redeem() re-reads the request lockForUpdate() inside a transaction and re-checks
status === 'approved' before minting, then flips it to redeemed. Two concurrent
polls on a leaked auth_req_id cannot both observe approved and each mint a
token. The pending/slow-down last_polled_at writes are committed outside that
transaction, so a rolled-back mint can never leave the client able to poll
unthrottled.
Honest limits
- Poll and ping, no push. Discovery advertises
backchannel_token_delivery_modes_supported: ["poll", "ping"]. Push (the token in the callback) is not implemented: ping hands the client only an already-spent-on-redeem handle, never a credential. - The approval channel is the host's to secure. CIBA decouples approval to a
second device, but the OP mints the token the moment
approve()is called — the strength of the notification and the device (is it phishing-resistant? is thebinding_messageactually shown?) is the host's responsibility. login_hintis an enumeration signal. Returningunknown_user_iddistinguishes a known from an unknown user. For a confidential client that is the CIBA-spec behavior; a self-registered public client (only where a resource allowed it) sees it too, bounded by the per-client-and-hint rate limit.- This is a primitive. Whether a given action warrants a CIBA approval, and what the approval prompt says, is the host's policy; the package enforces the grant mechanics.