Use case — CIBA (Client-Initiated Backchannel Authentication)
CIBA lets a client start an authentication for a user it already knows (a cashier's terminal, a call-centre console) while the user approves on their own device, typically a phone app. For the concepts, including how CIBA differs from device flow and why binding_message matters, read the CIBA primer first. This page covers the wiring.
Poll delivery — what is implemented?
CIBA defines poll, ping, and push delivery modes. This library implements poll only: the consumption device calls /token repeatedly until the answer arrives. Discovery advertises only poll, so a client cannot negotiate an unsupported mode.
auth_req_id — what's that?
The opaque identifier /bc-authorize returns to the consumption device. It is the CIBA counterpart of device_code: the device keeps it private and submits it on every /token poll. Unlike device flow there is no user-visible code, because your notification service delivers the prompt out of band and the consumption device only needs the polling handle.
binding_message — what's that?
A short human-readable string the consumption device sends in /bc-authorize and the OP stores with the request, for your notification service to show on the authentication device. The cashier's POS shows "Approve $80.00 at Acme Coffee, terminal #14"; the user's phone shows the same string in the approve dialog. It is the user's only evidence that the prompt belongs to the transaction in front of them. Without it, someone who triggers an unrelated CIBA request could get a user to approve a vague "Approve sign-in?" dialog. Treat it as required even though the spec says optional.
Poll mode only
The library implements poll delivery only. Discovery advertises backchannel_token_delivery_modes_supported: ["poll"] and nothing else. A client that sends client_notification_token or user_code gets 400 invalid_request.
Enabling CIBA
import (
"context"
"time"
"github.com/libraz/go-oidc-provider/op"
"github.com/libraz/go-oidc-provider/op/grant"
"github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)
st := inmem.New() // ships a CIBARequestStore substore
provider, err := op.New(
op.WithIssuer("https://op.example.com"),
op.WithStore(st),
// CIBA resolves end users, and there is no PrimaryPassword to infer the
// user store from, so name it.
op.WithUserStore(st.Users()),
op.WithKeyset(myKeyset),
op.WithCookieKeys(myCookieKey),
// Naming grant.CIBA replaces the default {authorization_code,
// refresh_token} pair: the consumption device never opens a browser.
op.WithGrants(grant.CIBA),
op.WithCIBAGrant(op.CIBAGrant{
HintResolver: myHintResolver,
PollInterval: 5 * time.Second, // optional; default 5s
DefaultRequestTTL: 10 * time.Minute, // optional; default 10min
MaxRequestTTL: 15 * time.Minute, // optional cap on `requested_expiry`
MaxPollViolations: 8, // optional; the default is 5
}),
op.WithStaticClients(op.ConfidentialClient{
ID: "pos-terminal",
Secret: posSecret, // at least 32 bytes
AuthMethod: op.AuthClientSecretBasic, // upgrade to private_key_jwt for FAPI-CIBA
GrantTypes: []string{"urn:openid:params:grant-type:ciba"},
Scopes: []string{"openid", "profile"},
}),
)op.WithCIBAGrant(...) does three things:
- Mounts
/bc-authorizeat the configured endpoint path. - Registers the CIBA URN (
urn:openid:params:grant-type:ciba) at/token. - Advertises
backchannel_authentication_endpoint,backchannel_token_delivery_modes_supported: ["poll"], andbackchannel_user_code_parameter_supported: falsein discovery. When JAR is also enabled, discovery addsbackchannel_authentication_request_signing_alg_values_supported.
The CIBA substore (store.CIBARequestStore) is required. The in-memory, SQL, and DynamoDB adapters each ship one; the SQL adapter persists to the oidc_ciba_requests table on sqlite, mysql, and postgres. The Redis adapter returns nil for this substore, so on a Redis-based deployment route CIBARequests to a durable tier (SQL or in-memory) through the composite adapter. op.New requires both Store.CIBARequests() and a HintResolver, and the resolver can only be supplied through op.WithCIBAGrant(...). op.WithGrants(grant.CIBA) on its own is refused; alongside op.WithCIBAGrant(...) it is a no-op, because op.WithCIBAGrant(...) already adds the grant.
CIBA resolves an end user from the hint, so op.New also needs a user store: op.WithUserStore, or the Store of an op.PrimaryPassword in op.WithLoginFlow.
op.New also refuses a negative DefaultRequestTTL, MaxRequestTTL or PollInterval, a DefaultRequestTTL above MaxRequestTTL, and, under profile.FAPICIBA, a default or maximum expiry above 10 minutes. A zero value means "use the library default" (600 seconds and 5 seconds).
Implementing HintResolver
CIBA requires the OP to know which user the embedder's approval service should address before any approval can happen. CIBAGrant.HintResolver is mandatory — calling WithCIBAGrant without it fails at op.New:
login_hint vs id_token_hint vs login_hint_token — what's the difference?
CIBA gives the consumption device three shapes for naming the user. login_hint is a free-form string the embedder interprets — email, account number, loyalty card. id_token_hint is a previously issued ID token. The OP verifies its signature against the OP keyset, its issuer, its typ header (a logout token or access token is refused), and that its audience is the authenticated client, then passes only the verified sub to your resolver. The OP does not enforce the hint token's expiry, because a terminal legitimately holds ID tokens from sessions that ended long ago. A pairwise client is rejected before the resolver because its sub is a per-sector pseudonym; use login_hint or login_hint_token for that client. login_hint_token is a signed JWT minted by some upstream system you trust (a federation IdP, a corporate directory) — your resolver verifies its signature, issuer, and audience according to that system's contract and maps it to a subject. The resolver must never parse the raw id_token_hint JWT.
op.HintKind and its values (op.HintLoginHint, op.HintIDTokenHint, op.HintLoginHintToken) are declared in the package op/ciba; package op re-exports them as aliases, so a resolver written against either spelling compiles as one type.
HintResolver — what's that?
The interface the OP calls once per /bc-authorize to turn your identifier for a person into a stable internal sub. Every deployment has its own user table, so the OP cannot do this itself. Resolve(ctx, kind, value) returns the subject string, op.ErrUnknownCIBAUser for an unknown user (wire code unknown_user_id), or any other error for a failed lookup (wire code login_required). An empty subject also becomes login_required.
type myHintResolver struct{ /* db handle */ }
func (r *myHintResolver) Resolve(ctx context.Context, kind op.HintKind, value string) (string, error) {
switch kind {
case op.HintLoginHint:
// value = "[email protected]", account number, loyalty card, ...
sub, err := r.lookupBy(ctx, value)
if errors.Is(err, sql.ErrNoRows) {
return "", op.ErrUnknownCIBAUser // → wire response: unknown_user_id
}
if err != nil {
return "", err // → wire response: login_required
}
return sub, nil
case op.HintIDTokenHint:
// value is the subject from an OP-verified id_token_hint, not a JWT.
// The OP checked signature + iss + aud, but intentionally not exp.
return lookupSubject(ctx, value)
case op.HintLoginHintToken:
// value is a signed JWT issued by another upstream system you trust.
// Verify its signature against your registered key and read its
// `sub` claim.
return r.verifyLoginHintToken(ctx, value)
}
return "", op.ErrUnknownCIBAUser
}Resolver is on the request hot path
Resolve is called once per /bc-authorize POST. Cache the lookup if your backing store is remote — the approval-notification path waits on this call.
op.HintResolverFunc adapts a plain function into a HintResolver.
The authentication-device callback
The OP does not own the channel that delivers the prompt to the user's phone; that is between your notification service and the user's app. The library's surface is the substore: when the user's app reports back, your callback handler calls CIBARequestStore.Approve (or Deny) on the same store (*inmem.Store or another adapter) that you passed to op.WithStore. The OP does not re-export its store, so keep your own reference.
// st is the same store passed to op.WithStore(st). The embedder retains
// the reference; there is no provider.Store() accessor.
func handleApproval(w http.ResponseWriter, r *http.Request, st *inmem.Store) {
authReqID := r.FormValue("auth_req_id")
decision := r.FormValue("decision") // "approve" or "deny"
sub := mustExtractSubFromAppSession(r)
switch decision {
case "approve":
// acr is the Authentication Context Class Reference the
// authentication device actually satisfied. The token endpoint
// stamps it verbatim onto id_token.acr; it is independent of the
// acr_values the consumption device requested at /bc-authorize,
// and may be left "" when the deployment has no ACR vocabulary.
// authTime is the wall-clock when the user authenticated on the
// authentication device. Token endpoint stamps id_token.auth_time
// from it (omit-on-zero); clients that registered RequireAuthTime
// enforce the gate against this value.
acr := "" // or the ACR the authentication device satisfied
if err := st.CIBARequests().Approve(r.Context(), authReqID, sub, acr, time.Now()); err != nil {
http.Error(w, "approve failed", 500)
return
}
case "deny":
if err := st.CIBARequests().Deny(r.Context(), authReqID, "user_denied"); err != nil {
http.Error(w, "deny failed", 500)
return
}
}
w.WriteHeader(204)
}The next /token poll from the consumption device succeeds, or returns access_denied after a deny. The subject you pass to Approve must be the one the HintResolver returned, and it must still exist in the user store: the token endpoint checks it on every redemption.
binding_message
The consumption device sends binding_message on every /bc-authorize request. The OP stores it on the request record (CIBARequest.BindingMessage), so your notification service can show the authentication device the same string the cashier sees:
curl -s -u pos-terminal:<secret> \
-d 'scope=openid profile' \
-d 'login_hint=alice' \
-d 'binding_message=Approve $80.00 at Acme Coffee, terminal #14' \
https://op.example.com/oidc/bc-authorizeThe OP trims the value, rejects one longer than 50 characters or containing a control character with 400 invalid_binding_message, and stores the raw text rather than an HTML-escaped copy. Escape it when you render it in the authentication-device UI. Do not pre-escape before sending it to /bc-authorize, or transaction text containing &, <, >, ", or ' stops matching what the cashier saw.
scope is required and must include openid. /bc-authorize splits it on any Unicode whitespace to tolerate lenient clients, but do not depend on tabs or newlines, because /auth and /par accept only the space-separated form (under an /oidc mount, /oidc/auth).
A successful request returns auth_req_id, expires_in, and interval. Exactly one of login_hint, id_token_hint, and login_hint_token must be present.
RFC 8707 resource=
Consumption devices may pin the issued access token to a resource server by sending resource=<absolute URI> on /bc-authorize. The endpoint applies the same rules as /auth and /token:
- The value MUST be an absolute URI (RFC 8707 §2). Relative URIs are rejected with
400 invalid_target. - The canonical form (lowercase scheme and host, trailing slash stripped) MUST appear in the client's registered
Resourcesallowlist. A resource the client was never registered for is refused with400 invalid_target. - Multiple non-empty
resource=values are rejected with400 invalid_target, because a CIBA request carries a single audience and the OP will not silently drop the others.
Register every resource server URI a consumption device may request in the client's Resources first.
Sender-constrained CIBA records
When DPoP or mTLS is used, /bc-authorize records the DPoP key thumbprint and/or the mTLS certificate thumbprint. At /token, the OP checks every binding on the record again before issuing a token. A request with both bindings needs both a matching DPoP proof and a matching mTLS certificate; one is not enough. A missing or mismatched proof makes the auth_req_id unredeemable (400 invalid_grant). On success, the token's cnf carries every binding the OP rechecked. A DPoP proof's htu is checked against the endpoint URL the OP advertises in discovery (or its mtls_endpoint_aliases URL), not a URL rebuilt from the request.
amr and acr in the CIBA id_token
The id_token issued at the end of a CIBA flow carries the acr that the authentication device actually satisfied, which is the acr argument your callback passes to CIBARequestStore.Approve. The access token carries the same acr, in JWT and opaque form alike. It is not taken from the requested acr_values, and it is empty when the callback passes "" (the expected choice for a deployment with no comparable ACR vocabulary). amr is never populated, because the CIBA request record carries no verified authentication-method signal; OIDC Core §2 treats acr and amr as separate claims. An RP that reads amr from a CIBA id_token should treat it as absent.
A redeemed request also records a consent grant for that client and subject. The tokens then resolve their subject at introspection and /userinfo (the pairwise value for a pairwise client), and a later /auth request for the same scopes can skip the consent screen.
FAPI-CIBA profile
op.WithProfile(profile.FAPICIBA) pins:
RequiredFeatures=[JAR]—/bc-authorizerequests must be JWT-Secured (RFC 9101).RequiredGrants=[CIBA]—op.Newfails if the CIBA grant is not enabled; the profile does not enable it for you.RequiredAnyOf=[[DPoP, MTLS]]— sender constraint is mandatory; DPoP is selected by default unless the deployment explicitly enables mTLS. DPoP also needs a nonce source (op.WithDPoPNonceSource, see DPoP nonce);op.Newrefuses it otherwise.MaxAccessTokenTTL= 10 min.- Client authentication =
private_key_jwt, or withfeature.MTLStls_client_authandself_signed_tls_client_auth(client_secret_basicrejected), at/bc-authorizeand/tokenalike. Unlike the FAPI 2.0 profiles, FAPI-CIBA does not restrict a client assertion'saudto the issuer, and refresh tokens still rotate. RequiresAccessTokenRevocation= true.- JAR enforcement on
/bc-authorize:iss/aud/exp/nbf/iat/jtiare all required; the library'sMaxRequestObjectAgealso caps the request object'siatage at 60 minutes. That is a library verification bound. Separately, FAPI-CIBA-ID1 §5.2.2 item 9 limits the signed authentication request'snbf/explifetime to 60 minutes; this is not FAPI 2.0 Message Signing Final §5.6, whose text covers ID-token signing. FAPI 2.0 Baseline and Message Signing keepjtioptional; FAPI-CIBA opts into the stricter shape. requested_expiry > 600sis a hardinvalid_requestunder this library's FAPI-CIBA setting. The 10-minute ceiling comes from the historical FAPI-CIBA-ID1 profile and this library's constraints, not a section of the final FAPI 2.0 Security Profile. Vanilla CIBA keeps the silent-clamp posture.- Every client-attributable JAR failure at
/bc-authorize(signature mismatch, unsupported alg, missing required claim, fetch failure onrequest_uri, …) maps to400 invalid_requestper CIBA Core §13, which has no finer code than/auth'sinvalid_request_object. A fault on the OP's side of fetching the client's keys (JWKS overloaded or temporarily failing) is503 server_errorinstead.
When op.WithACRValuesSupported(...) is non-empty, the endpoint checks each requested acr_values entry against the published list and answers 400 invalid_request for an unknown one. With an empty list, any value is accepted as is.
Polling responses
Same shape as the device-code grant:
| Wire response | Meaning |
|---|---|
400 authorization_pending | User has not approved yet. Poll again after the negotiated interval. |
400 slow_down | Polled too fast. Honour the raised interval (the server stores it). |
400 access_denied | The user denied, an admin revoked the request, or the poll-abuse cap (CIBAGrant.MaxPollViolations, default 5) tripped. Stop polling. |
400 expired_token | auth_req_id outlived its lifetime (TTL elapse only — RFC 6749 §5.2 / CIBA Core §11). Stop polling. |
400 invalid_grant | auth_req_id was already redeemed, or a DPoP / mTLS binding on the request is missing or does not match. Do not retry with the same handle. |
200 { access_token, ... } | Approved. |
The OP counts each poll made before the negotiated interval has elapsed as a strike against the auth_req_id. When the strikes reach the cap (default 5), the request is locked out: every later poll returns 400 access_denied, and the ciba.poll_abuse.lockout audit event is recorded. CIBAGrant.MaxPollViolations (a uint8) changes the cap; n=0 means the library default, and n=255 effectively disables the lockout, which suits only diagnostic builds.
Duplicate single-valued parameters at /bc-authorize
A request that repeats client_id, login_hint, id_token_hint, login_hint_token, binding_message, requested_expiry, acr_values, scope, user_code, or client_assertion is refused with 400 invalid_request per CIBA Core §13. Only resource= (RFC 8707) may appear more than once. The token endpoint, /end_session, and /revoke apply the same rule to their own single-valued parameters.
See it run
(cd examples/32-ciba-pos && GOWORK=off go run -tags example .)A POS terminal posts to /bc-authorize, a goroutine standing in for the staff phone calls CIBARequestStore.Approve directly, and the POS polls until the OP issues the token. It takes about five seconds. Files: op.go (OP wiring + HintResolver), rp.go (POS-side polling), device.go (simulated phone approval).
Read next
- CIBA primer — the conceptual background.
- Device Code wiring — when the user's surface includes a screen with a typed code.
- FAPI 2.0 baseline — the parent profile FAPI-CIBA inherits from.