Skip to content

CIBA backchannel authentication ​

CIBA (Client Initiated Backchannel Authentication) is for scenarios where the initiator cannot reach the user's browser: for example, a service counter system handling a transaction for a customer, or a call center verifying a caller's identity. The client calls wicket from its backend, wicket records a pending request, the host application notifies the user and records the user's decision, and the client polls the token endpoint to get tokens.

All requests and responses on this page are actual results from running on v0.1.4. All Go snippets compile and have been run in the experimental program.

Scope of support ​

Itemv0.1.4 status
Endpoint POST /connect/cibaRegistered
Token delivery modespoll only (discovery document backchannel_token_delivery_modes_supported: ["poll"])
ping / pushNot supported (ping has notification code, but it is not in the allowed list and is unverified; push is not implemented)
User confirmationNo built-in interaction service; the host updates the request store directly (see below)
id_tokenNot issued: even if the request includes openid, id_token in the token response is an empty string
Refresh tokensIssued when the client has AllowOfflineAccess and the request includes offline_access

1. Configure the client ​

go
ciba := models.NewClient()
ciba.ClientId = "ciba-client"
secret := models.NewSecret()
secret.Value = utils.Sha256("ciba-secret")
ciba.ClientSecrets = []*models.Secret{secret}
ciba.AllowedScopes = []string{"openid", "orders.read"}
ciba.AllowOfflineAccess = true // optional
if err := ciba.SetAllowedGrantTypes(wicket.GrantTypesBackchannelAuthentication); err != nil {
	log.Fatal(err)
}
// Optional: override the global defaults
// interval := 10; ciba.PollingInterval = &interval
// lifetime := 120; ciba.BackchannelAuthenticationLifetime = &lifetime

Leaving BackchannelTokenDeliveryMode empty means poll. Global defaults live in WicketOptions.BackchannelAuthentication:

OptionDefaultNotes
AllowedDeliveryModes["poll"]Allowed delivery modes
DefaultRequestLifetime300 secondsRequest lifetime; the client's requested_expiry can only shorten it
DefaultPollingInterval5 secondsThe interval returned to the client
SlowDownIntervalIncrement5 secondsHow much the request's polling interval increases each time slow_down is triggered

In v0.1.4, no code reads BackchannelAuthentication.Enabled, so you cannot use it to turn CIBA off.

2. Replace the default user validator ​

The default validator is not for production

The default BackchannelAuthenticationUserValidator that wicket uses when none is injected is only for development and integration testing: it does not resolve or verify user hints against the host user store. In production, you must inject your own BackchannelAuthenticationUserValidator that accepts only hints resolving to real users; id_token_hint and login_hint_token must have their signatures validated before use.

Inject your own implementation that resolves the hint to a real user in the host user store:

go
// userValidator resolves login_hint to a real user in the host user store.
type userValidator struct {
	lookup func(ctx context.Context, hint string) (subjectID string, ok bool)
}

func (v *userValidator) Validate(ctx context.Context, c *validators_models.BackchannelAuthenticationUserValidationContext) error {
	if c.LoginHint == "" {
		// This example does not accept id_token_hint / login_hint_token; to support them, validate the signature before reading sub
		return errors.New("login_hint required")
	}
	sub, ok := v.lookup(ctx, c.LoginHint)
	if !ok {
		return errors.New("unknown user")
	}
	p, err := wicketmodels.NewLocalUser(wicketmodels.WithLocalUserSubjectId(sub)).CreatePrincipal()
	if err != nil {
		return err
	}
	c.Subject = p
	return nil
}

cfg.SetBackchannelAuthenticationUserValidator(&userValidator{lookup: findUserByHint})

When Validate returns any error, the endpoint always responds with invalid_request (observed for an unknown user: {"error":"invalid_request","error_description":""}).

Rely on Subject

In v0.1.4, the SubjectId field of the stored record is not necessarily equal to the user's sub; do not rely on it. Subject is the user principal resolved by the validator, and the sub in tokens comes from Subject. When the host notifies the user, read sub from Subject.

3. Send the request ​

bash
curl -s -u ciba-client:ciba-secret \
  -d "scope=openid orders.read" \
  -d login_hint=alice@example.com \
  -d "binding_message=Pay 42" \
  http://localhost:5000/connect/ciba
json
{"auth_req_id":"zUzdxlNeR9ql20ix3rJQ8W29kEw","expires_in":300,"interval":5}
ParameterNotes
scopeRequired; returns invalid_scope when missing
login_hint / login_hint_token / id_token_hintAt least one; returns invalid_request if all are missing
binding_messageShort text shown to the user for cross-checking
user_codeA code the user set up in advance; stored as is, and the host decides whether to check it
requested_expiryIn seconds; can only be shorter than the default
resourceResource indicator; may be repeated

Client authentication works the same as at the token endpoint (client_secret_basic / client_secret_post). A client not allowed the CIBA grant type gets unauthorized_client. Note that error bodies from this endpoint lack the custom field that the token endpoint includes.

4. Notify the user: wrap the request store ​

After a request passes validation, wicket calls BackchannelAuthenticationRequestStore.StoreBackchannelAuthenticationRequest to save it, and notifies no one else. The most direct integration point is to wrap this store and push to the user after a successful save:

go
type notifyingRequests struct {
	storage.BackchannelAuthenticationRequestStore
	notify func(req *models.BackchannelAuthenticationRequest)
}

func (s *notifyingRequests) StoreBackchannelAuthenticationRequest(ctx context.Context, id string, req *models.BackchannelAuthenticationRequest) error {
	if err := s.BackchannelAuthenticationRequestStore.StoreBackchannelAuthenticationRequest(ctx, id, req); err != nil {
		return err
	}
	s.notify(req) // req.RequestId, req.Subject, req.BindingMessage, and req.RequestedScopes are available
	return nil
}

requests := &notifyingRequests{
	BackchannelAuthenticationRequestStore: myStore, // for development, use memory.NewMemoryBackchannelAuthenticationRequestStore()
	notify:                                pushToUserDevice,
}
cfg.SetBackchannelAuthenticationRequestStore(requests)

notify runs synchronously on the request path; hand off slow work to the host's own queue.

5. Record the user's decision ​

After the user taps "Approve / Deny" in an app or a web page, the host updates the request status directly:

go
func decide(ctx context.Context, store storage.BackchannelAuthenticationRequestStore, requestID string, approve bool) error {
	req, err := store.FindBackchannelAuthenticationRequest(ctx, requestID)
	if err != nil {
		return err
	}
	if req.Status != models.BackchannelAuthenticationRequestStatusPending {
		return errors.New("request is no longer pending")
	}
	if approve {
		req.Status = models.BackchannelAuthenticationRequestStatusApproved
	} else {
		req.Status = models.BackchannelAuthenticationRequestStatusDenied
	}
	return store.UpdateBackchannelAuthenticationRequest(ctx, requestID, req)
}

The confirmation page must first verify that the currently signed-in user is the user corresponding to req.Subject before calling decide. Status values are pending, approved, denied, and consumed; after issuing tokens, wicket sets the status to consumed.

6. Client polling ​

bash
curl -s -u ciba-client:ciba-secret \
  -d grant_type=urn:openid:params:grant-type:ciba \
  -d auth_req_id=$AUTH_REQ_ID \
  http://localhost:5000/connect/token

Responses at each stage (the "belongs to another client" case is based on the source code; all others were observed):

WhenResponse
User has not yet confirmed{"custom":{},"error":"authorization_pending","error_description":""}
Less than interval seconds since the last pollslow_down (the request's interval increases by SlowDownIntervalIncrement)
User approved200 with access_token (sub is the confirming user); id_token is an empty string
User deniedaccess_denied
Past the lifetimeexpired_token
Already redeemed / auth_req_id belongs to another client / does not existinvalid_grant

Clients should honor the returned interval, and after receiving slow_down, increase the interval by 5 seconds before retrying.

Released under the Apache License 2.0 · Documentation © 2026 Wicket authors