Skip to content

Use case — CIBA (Client-Initiated Backchannel Authentication)

For the conceptual background — what CIBA is, how it differs from device flow, why binding_message matters — read the CIBA primer first. This page covers the wiring.

The three parties, and what sits between them
The consumption device asks the OP to start an authorization, your notification service reaches the registered phone, the user approves there, and the consumption device polls the token endpoint until a decision lands.Consumption devicePOS · shared terminalno screen the user trustsOP/bc-authorizestarts the requestCIBARequestStoreholds it until a decision/tokenanswers the pollingAuthentication devicethe user's phone appregistered in advance12341 · the terminal asks the OP to start an authorization2 · your notification service reaches the registered phone3 · the user approves or denies, out of band4 · the terminal polls /token until the decision lands
Step 2 is the piece you have to build. The library holds the request and answers the polling; getting a prompt onto that phone is your notification service's job, not the OP's.
Poll delivery — what is implemented?

CIBA defines poll, ping, and push delivery modes. This library implements poll only: the consumption device hits /token repeatedly until the answer arrives. Discovery advertises only poll, so clients 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 equivalent of device_code — the device keeps it private and submits it on every /token poll. Unlike device-code there is no separate user-visible code; the embedder's authentication-device service presents the prompt out of band, so 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 forwards to the authentication device's prompt. 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. This is the only signal the user has that the prompt is genuinely for the transaction in front of them — without it, a phisher who triggered an unrelated CIBA request could trick a user into approving on a vague "Approve sign-in?" dialog. Treat it as required even though the spec says optional.

Poll mode only

The library implements poll delivery. Discovery advertises backchannel_token_delivery_modes_supported: ["poll"] exclusively. This OP does not provide ping or push delivery.

Enabling CIBA

go
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"
)

provider, err := op.New(
  op.WithIssuer("https://op.example.com"),
  op.WithStore(inmem.New()), // ships a CIBARequestStore substore
  op.WithKeyset(myKeyset),
  op.WithCookieKeys(myCookieKey),

  op.WithGrants(grant.CIBA),
  op.WithCIBA(
    op.WithCIBAHintResolver(myHintResolver),
    op.WithCIBAPollInterval(5 * time.Second),       // optional; default 5s
    op.WithCIBADefaultExpiresIn(10 * time.Minute),  // optional; default 10min
    op.WithCIBAMaxExpiresIn(15 * time.Minute),      // optional cap on `requested_expiry`
    op.WithCIBAMaxPollViolations(8),                // optional; raise above the 5-strike default
  ),

  op.WithStaticClients(op.ConfidentialClient{
    ID:         "pos-terminal",
    Secret:     posSecret,
    AuthMethod: op.AuthClientSecretBasic, // upgrade to private_key_jwt for FAPI-CIBA
    GrantTypes: []string{"urn:openid:params:grant-type:ciba"},
    Scopes:     []string{"openid", "profile"},
  }),
)

op.WithCIBA(...) does three things:

  1. Mounts /bc-authorize at the configured endpoint path.
  2. Registers the CIBA URN (urn:openid:params:grant-type:ciba) at /token.
  3. Advertises backchannel_authentication_endpoint, backchannel_token_delivery_modes_supported: ["poll"], and backchannel_user_code_parameter_supported: false in discovery. When JAR is also enabled, discovery adds backchannel_authentication_request_signing_alg_values_supported.

The CIBA substore (store.CIBARequestStore) is required. The in-memory and SQL adapters both ship one — the SQL adapter persists to the oidc_ciba_requests table across sqlite / mysql / postgres. The Redis adapter returns nil for this substore, so on a Redis-only deployment route CIBARequests to a durable tier (SQL or in-memory) through the composite adapter. op.New enforces this whether the grant is activated via op.WithCIBA(...) or via op.WithGrants(grant.CIBA, ...) — both code paths require Store.CIBARequests() and a HintResolver to be wired before construction succeeds.

Implementing HintResolver

CIBA requires the OP to know which user the embedder's approval service should address before any approval can happen. op.WithCIBAHintResolver(...) is mandatory — calling WithCIBA 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, and the authenticated client's audience, then passes only its verified sub to your resolver. The OP intentionally does not enforce the hint token's expiry. 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.

HintResolver — what's that?

The interface the OP calls once per /bc-authorize to translate "the embedder's idea of who this is" into a stable internal sub. The OP cannot guess this — every embedder has its own user table. Resolve(ctx, kind, value) returns the subject string (or op.ErrUnknownCIBAUser for unknown / login_required for transient lookup errors). It runs on the request hot path, so cache lookups against remote stores.

go
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.

For one-off / functional use, 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's a cooperation between the embedder's notification service and the user's app. The library's surface is the substore: when the user's app reports back, the embedder's callback handler calls CIBARequestStore.Approve (or Deny) directly on the same *inmem.Store (or other adapter) the embedder passed to op.WithStore — there is no provider.Store() accessor; the OP does not re-export the store, the embedder is expected to retain the reference.

go
// 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 will succeed (or return access_denied).

binding_message

Pass binding_message from the consumption device on every /bc-authorize post. The OP forwards it through the substore record so the embedder's authentication-device notification can render the same string the cashier sees:

sh
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-authorize

This is the user's only defense against a CIBA phishing flow. Treat it as required in the embedder UX even though the spec marks it optional.

The OP validates binding_message (trimmed length and control-character checks) and persists the raw value, not an HTML-escaped copy. Escape it at render time in your authentication-device UI; do not pre-escape before sending it to /bc-authorize, or transaction text containing &, <, >, ", or ' will stop matching what the cashier saw.

For scope, the CIBA handler accepts the same spec-conformant ASCII-space form clients should send, and also tokenizes Unicode whitespace with strings.Fields for lenient CIBA clients. Do not depend on tabs or newlines for cross-endpoint portability; /auth and /par keep the stricter wire grammar (under an /oidc mount, /oidc/auth).

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 enforces the same gate 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 + host, trailing slash stripped) MUST appear in the client's registered Resources allowlist; a request that names a resource the client was never registered for is refused with 400 invalid_target.
  • Multiple non-empty resource= values are rejected with 400 invalid_target. The CIBA issuance pipeline accepts one audience entry; it rejects multiple values rather than silently truncating them.

resource= must be registered

resource= values must be absolute URIs and must appear in the client's registered Resources allowlist. Requests outside that allowlist are rejected with invalid_target.

Sender-constrained CIBA records

When DPoP or mTLS is used, /bc-authorize records the DPoP key thumbprint and/or mTLS certificate thumbprint. At /token, every binding present on the record is checked again before issuing a token. A CIBA request carrying both bindings requires both a matching DPoP proof and a matching mTLS certificate; presenting only one is insufficient. A missing or mismatched proof means the auth_req_id cannot be redeemed. On success, the issued token's cnf carries every binding the OP reverified.

amr and acr in the CIBA id_token

The id_token issued at the end of a CIBA flow stamps acr from the ACR the authentication device actually satisfied — the value the embedder's callback passes as the acr argument to CIBARequestStore.Approve — not from the requested acr_values. It is empty when the callback passes "", which is the expected posture for deployments with no comparable ACR vocabulary. amr is not populated because the CIBA request record does not carry a verified authentication-method signal. OIDC Core §2 defines acr and amr as distinct concepts with no defined synonymy.

If your RP reads amr from a CIBA id_token, treat the claim as empty or absent. The current CIBA request record carries no verified authentication-method signal.

FAPI-CIBA profile

op.WithProfile(profile.FAPICIBA) pins:

  • RequiredFeatures = [JAR]/bc-authorize requests must be JWT-Secured (RFC 9101).
  • RequiredAnyOf = [[DPoP, MTLS]] — sender constraint is mandatory; DPoP is selected by default unless the deployment explicitly enables mTLS.
  • MaxAccessTokenTTL = 10 min.
  • Client authentication = private_key_jwt (client_secret_basic rejected). mTLS may satisfy the sender-constraint requirement when feature.MTLS is configured, but not the /token client-auth method.
  • RequiresAccessTokenRevocation = true.
  • JAR enforcement on /bc-authorize: iss / aud / exp / nbf / iat / jti are all required; the request-object lifetime is capped at 60 minutes (FAPI 2.0 Message Signing §5.6). FAPI 2.0 Baseline and Message Signing keep jti optional; FAPI-CIBA opts into the stricter shape.
  • requested_expiry > 600s is a hard invalid_request (FAPI-CIBA-ID1 §5 / FAPI 2.0 §3.1.9 ten-minute cap). Vanilla CIBA keeps the silent-clamp posture.
  • Every JAR failure at /bc-authorize (signature mismatch, unsupported alg, missing required claim, fetch failure on request_uri, …) maps to 400 invalid_request per CIBA Core §13. The vanilla /auth JAR pipeline keeps its richer error vocabulary; CIBA collapses it because the spec leaves no room for a finer breakdown on the back-channel surface.

When op.WithACRValuesSupported(...) is non-empty, the endpoint validates each requested acr_values entry against the published list. Empty list keeps the legacy permissive posture.

Polling responses

Same shape as the device-code grant:

Wire responseMeaning
400 authorization_pendingUser has not approved yet. Poll again after the negotiated interval.
400 slow_downPolled too fast. Honour the elevated interval (server persists it).
400 access_deniedUser denied, admin revoked, or the poll-abuse cap (WithCIBAMaxPollViolations, default 5) tripped. Stop polling.
400 expired_tokenauth_req_id outlived its lifetime (TTL elapse only — RFC 6749 §5.2 / CIBA Core §11). Stop polling.
400 invalid_grantauth_req_id was already redeemed. The grant is gone; do not retry with the same handle.
200 { access_token, ... }Approved.

The OP tracks "polled before the negotiated interval elapsed" as a strike against the auth_req_id. Once the strike count reaches the cap (default 5), the request is locked out — every subsequent poll returns 400 access_denied and the ciba.poll_abuse.lockout audit event fires. op.WithCIBAMaxPollViolations(n uint8) raises or lowers the cap when a profile or conformance harness demands more headroom; n=0 falls back to the library default, n=255 effectively disables the lockout for 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 RFC 8707 resource= may legitimately appear more than once. The token endpoint, /end_session, and /revoke apply the same rule to their respective single-valued parameters.

See it run

examples/32-ciba-pos:

sh
(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; the POS polls until the OP issues the token. End-to-end ≈ 5 seconds. Files: op.go (OP wiring + HintResolver), rp.go (POS-side polling), device.go (simulated phone approval).