- Published on
Advanced OAuth Flows — When You Need CIBA, Device Flow, and DPoP
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction
- Situations Where the Standard Redirect Fails
- Device Authorization Grant (RFC 8628)
- CIBA — Decoupled Authentication
- DPoP (RFC 9449) — Binding the Token to a Key
- Combining Flows, and PAR
- Security Considerations
- Hands-On: A Device Flow Client in bash
- Selection Guide
- The 2026 Context — a Device Flow Renaissance for AI Agents and CLIs
- Closing Thoughts
- References
Introduction
The standard OAuth scenario is clear: a user accesses a service in a browser, gets redirected to the authorization server, logs in, and comes back to the service. Authorization Code Flow + PKCE works well for this, and the OAuth 2.1 draft consolidates it as effectively the only user-facing flow.
The problem is that situations where a browser redirect is impossible are more common than you might think. A smart TV has no keyboard, a CLI tool may have no browser, and a call-center agent cannot touch the customer's device. And the hottest case as of 2026 — an AI agent must access APIs on behalf of a user, yet there is no way to receive a redirect inside the agent process. With OAuth establishing itself as the standard authorization mechanism in the MCP (Model Context Protocol) ecosystem, and Keycloak 26.6 experimentally supporting the OAuth Client ID Metadata Document (CIMD) so it can act as an MCP authorization server, the "authentication for browserless devices" problem has once again become a central topic.
In this article we examine three mechanisms — the Device Authorization Grant (RFC 8628), CIBA, and DPoP (RFC 9449) — at the wire level, and walk through Keycloak configuration, security considerations, and a selection guide.
Situations Where the Standard Redirect Fails
First, let us map the problem space. Typical situations where redirect-based flows break down:
| Situation | Constraint | Suitable mechanism |
|---|---|---|
| Smart TVs, consoles, IoT | Poor input, no or awkward browser | Device Flow |
| User auth from CLI tools, daemons | Local browser availability uncertain | Device Flow (or loopback redirect) |
| Call-center agent needs customer authentication | Authenticating party and requesting party on different devices | CIBA |
| Customer approval at POS/kiosk | Approval must happen on the customer phone | CIBA |
| Delegated access for AI agents | Agent has no UI | Device Flow + Token Exchange |
| Hardening against token theft | Limits of bearer tokens | DPoP (token binding, not a flow) |
A caution: DPoP is not an authentication flow but a token-constraining mechanism. It can be combined with any flow, and the three must not be viewed as the same kind of thing.
Device Authorization Grant (RFC 8628)
RFC 8628 is a flow that separates "the input-constrained device" from "the user's smartphone/PC browser." The TV shows a code; the user enters that code on their phone and approves.
+----------+ +---------------+
| TV/CLI | | Auth Server |
| (device) | | |
+----+-----+ +-------+-------+
| (1) POST /device_authorization |
| ------------------------------------>|
| (2) device_code + user_code + |
| verification_uri |
| <------------------------------------|
| |
| (3) shown on screen: |
| "Go to example.com/device and |
| enter code WDJB-MJHT" |
| |
| +--------+ (4) via phone |
| | User | ----------------->|
| | phone | code + login + |
| +--------+ consent |
| |
| (5) meanwhile the device polls |
| POST /token (device_code) |
| ------------------------------------>|
| authorization_pending ... |
| <------------------------------------|
| (6) after user approval, |
| access_token issued |
| <------------------------------------|
v v
The whole process in HTTP
Step 1: the device calls the device authorization endpoint.
POST /realms/prod/protocol/openid-connect/auth/device HTTP/1.1
Host: idp.example.com
Content-Type: application/x-www-form-urlencoded
client_id=smart-tv-app&scope=openid%20profile
The response:
{
"device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
"user_code": "WDJB-MJHT",
"verification_uri": "https://idp.example.com/realms/prod/device",
"verification_uri_complete": "https://idp.example.com/realms/prod/device?user_code=WDJB-MJHT",
"expires_in": 600,
"interval": 5
}
The device displays user_code and verification_uri on screen (showing verification_uri_complete as a QR code is the common UX). It then polls the token endpoint at intervals of interval seconds.
POST /realms/prod/protocol/openid-connect/token HTTP/1.1
Host: idp.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code
&device_code=GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS
&client_id=smart-tv-app
If the user has not approved yet, this comes back:
{
"error": "authorization_pending",
"error_description": "The authorization request is still pending"
}
If polling is too aggressive, a slow_down error signals the client to increase the interval. Once the user has entered the code on their phone and completed login/consent, the next poll returns a normal token response.
Keycloak configuration
In Keycloak, the Device Flow is enabled per client.
# enable the device authorization grant on a client
kcadm.sh update clients/CLIENT-UUID -r prod \
-s 'attributes."oauth2.device.authorization.grant.enabled"="true"'
# tune realm-level code lifetime / polling interval
kcadm.sh update realms/prod \
-s 'attributes."oauth2DeviceCodeLifespan"="600"' \
-s 'attributes."oauth2DevicePollingInterval"="5"'
For CLI tools, public client + PKCE is the default combination. Keycloak ships a built-in user_code entry page, which can be customized via themes.
CIBA — Decoupled Authentication
CIBA (Client Initiated Backchannel Authentication) is the decoupled flow defined by the OpenID Foundation's CIBA Core specification. It points in the opposite direction from the Device Flow. In the Device Flow the user carries a code and "goes to" the authorization server; in CIBA the authorization server "comes to" the user's authentication device.
The classic scenario is a call center. The agent submits an authentication request with a customer identifier (such as a phone number); a push notification appears on the customer's phone asking "Allow the agent to view your account?"; once the customer approves, the agent's system receives a token. The same pattern serves high-value payment approval at a POS and manager approval for back-office operations.
+-----------+ +-----------+ +----------+
| Agent | | Auth | | Customer |
| system(CC)| | Server | | phone |
+-----+-----+ +-----+-----+ +----+-----+
| (1) POST /ext/ciba/auth | |
| login_hint=customer id | |
| --------------------------------->| |
| (2) auth_req_id returned | |
| <---------------------------------| |
| | (3) push to auth device |
| | ------------------------->|
| | (4) customer approves |
| | with biometrics |
| | <-------------------------|
| (5) poll: POST /token | |
| grant_type=ciba, auth_req_id | |
| --------------------------------->| |
| (6) access_token | |
| <---------------------------------| |
v v v
The backchannel authentication request
POST /realms/prod/protocol/openid-connect/ext/ciba/auth HTTP/1.1
Host: idp.example.com
Authorization: Basic Y2FsbC1jZW50ZXI6czNjcjN0
Content-Type: application/x-www-form-urlencoded
login_hint=customer-01087654321
&scope=openid%20account%3Aread
&binding_message=CS-4711
&requested_expiry=120
The binding_message is a short code displayed simultaneously on both screens (the agent's and the customer's phone). It is an anti-phishing device that lets the customer confirm "the approval request that just appeared is the one for the call I am on." The response:
{
"auth_req_id": "1c266114-a1be-4252-8ad1-04986c5b9ac1",
"expires_in": 120,
"interval": 5
}
poll, ping, push — three token delivery modes
CIBA defines three modes for how the client receives the result.
| Mode | Behavior | Client requirements | Notes |
|---|---|---|---|
| poll | Client polls the token endpoint periodically | None (simplest) | Pattern similar to Device Flow |
| ping | On approval, AS notifies the client notification endpoint; client then calls the token endpoint | Callback endpoint needed | Reduces polling load |
| push | AS delivers the token itself directly to the client callback | Callback + strong security demands | Forbidden in the FAPI-CIBA profile |
The token request in poll mode:
POST /realms/prod/protocol/openid-connect/token HTTP/1.1
Host: idp.example.com
Authorization: Basic Y2FsbC1jZW50ZXI6czNjcjN0
Content-Type: application/x-www-form-urlencoded
grant_type=urn%3Aopenid%3Aparams%3Agrant-type%3Aciba
&auth_req_id=1c266114-a1be-4252-8ad1-04986c5b9ac1
CIBA in Keycloak
Keycloak supports the poll and ping modes. The fundamental difficulty in CIBA is "how to signal the user's authentication device," and Keycloak takes the approach of delegating this to an external authentication entity. You configure the backchannel authentication method in the realm's CIBA policy, and the actual push/approval handling is integrated by your company's mobile app and authentication server over HTTP callbacks.
# example realm CIBA policy settings
kcadm.sh update realms/prod \
-s 'attributes."cibaBackchannelTokenDeliveryMode"="poll"' \
-s 'attributes."cibaExpiresIn"="120"' \
-s 'attributes."cibaInterval"="5"'
# enable the CIBA grant on a client
kcadm.sh update clients/CLIENT-UUID -r prod \
-s 'attributes."oidc.ciba.grant.enabled"="true"'
Plan for the fact that the largest implementation effort is not the Keycloak configuration but the authentication-device side (push reception, approval UI, result callbacks).
DPoP (RFC 9449) — Binding the Token to a Key
DPoP is a sender-constraining mechanism that enforces "this token can only be used by the holder of a specific key pair." A bearer token can be used as-is if stolen; a DPoP token is useless to a thief without the private key.
Structure of the proof JWT
For every request, the client creates a JWT called a DPoP proof and sends it in the DPoP header. The proof's header carries the public key (jwk); the payload carries request-binding information.
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs",
"y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA"
}
}
{
"jti": "-BwC3ESc6acc2lTc",
"htm": "POST",
"htu": "https://idp.example.com/realms/prod/protocol/openid-connect/token",
"iat": 1781234567
}
- jti: a unique proof ID, used by the server to detect reuse
- htm/htu: the HTTP method and URI for which this proof is valid; blocks reuse against other endpoints
- iat: issuance time; rejected outside the allowed time window (typically tens of seconds)
At token issuance, the authorization server pins the thumbprint of the proof's public key (jkt) into the access token's cnf claim. Subsequent API calls carry both pieces.
GET /api/accounts HTTP/1.1
Host: api.example.com
Authorization: DPoP eyJhbGciOiJFUzI1NiIs...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs...
The resource server's validation order: (1) verify the proof signature with the public key inside the proof, (2) confirm the thumbprint of that key matches jkt in the token's cnf, (3) validate htm/htu/iat/jti, and (4) if the resource server requires it, also check the proof's ath claim (a hash of the access token).
Layers of replay defense
DPoP's replay defense is layered. htu/htm blocks reuse against other requests, the iat window filters out stale proofs, and jti tracking catches resubmission of the same proof. Additionally, the server can demand its own nonce via the DPoP-Nonce header; in that case the proof must include the server nonce, which categorically blocks pre-generated replays. When a nonce is required, the server returns a 400 with the use_dpop_nonce error and a fresh nonce, and the client rebuilds the proof and retries. Always verify that your client SDK handles this retry loop.
DPoP in Keycloak
Keycloak 26.x supports DPoP and can enforce it per client.
# enforce DPoP binding on a client
kcadm.sh update clients/CLIENT-UUID -r prod \
-s 'attributes."dpop.bound.access.tokens"="true"'
Once enabled, the token endpoint requires a DPoP proof, and issued tokens include cnf/jkt. Also review the validation logic on the gateway/resource-server side — especially htu mismatches caused by proxies rewriting URLs.
Combining Flows, and PAR
The three mechanisms are not mutually exclusive. Useful combinations in practice:
- Device Flow + DPoP: a CLI tool obtains tokens via the device flow but binds them with DPoP, so even if the token file leaks, it is unusable without the key file.
- CIBA + FAPI: decoupled approval in finance follows the FAPI-CIBA profile (push mode forbidden, signed requests, and other hardening requirements).
- PAR + redirect flow: for cases like kiosks where a redirect is possible but request integrity matters, register the authorization request over the back channel with PAR (RFC 9126) and send only a short request_uri to the front. PAR shares its philosophy — "backchanneling the request" — with the device flow and CIBA backchannel requests.
Security Considerations
Device code phishing
The biggest weakness of the Device Flow is the absence of user-side verification. An attacker starts the flow on their own device and sends the victim a verification_uri_complete link containing the user_code with a message like "please sign in for a security check." The victim's approval delivers a token to the attacker's device. Device code phishing has in fact been a staple technique of state-backed attack groups in recent years.
Mitigations:
- Show device information and a warning on the consent screen ("This request was initiated from a TV app. If you did not start it, deny it.")
- Short user_code lifetimes, attempt limits, and rate limiting
- Client policies that block device-flow issuance for sensitive scopes
- Anomaly detection: alert on geographic mismatch between the device-request IP and the approval IP
- User education and display of binding context (request time, location)
CIBA risks — approval fatigue and indiscriminate requests
Because CIBA structurally allows an attacker to send approval pushes to arbitrary users, patterns like MFA push fatigue attacks become possible. Strictly limit which clients may target users via login_hint, make binding_message display mandatory, and rate-limit approval requests per user. Keep requested_expiry as short as possible.
Limits of DPoP
DPoP is strong against token theft, but it cannot help if the device holding both key and token is itself compromised. Also, unless the proof-signing key lives in non-extractable storage (WebCrypto, Secure Enclave, TPM), the defensive value drops sharply. Clock skew (iat validation failures) and proxy URL rewriting (htu mismatches) are the most common operational failure points.
Hands-On: A Device Flow Client in bash
To cement the concepts, let us build a minimal device flow client using nothing but curl and jq. You can use it as-is as the skeleton for a CI script or an internal CLI tool.
#!/usr/bin/env bash
set -euo pipefail
IDP="https://idp.example.com/realms/prod/protocol/openid-connect"
CLIENT_ID="internal-cli"
# 1. device authorization request
resp=$(curl -s -X POST "$IDP/auth/device" \
-d "client_id=$CLIENT_ID" -d "scope=openid profile")
device_code=$(echo "$resp" | jq -r .device_code)
user_code=$(echo "$resp" | jq -r .user_code)
verification_uri=$(echo "$resp" | jq -r .verification_uri)
interval=$(echo "$resp" | jq -r .interval)
echo "Open $verification_uri in your browser"
echo "and enter the code [$user_code]."
# 2. poll until approved
while true; do
sleep "$interval"
token_resp=$(curl -s -X POST "$IDP/token" \
-d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
-d "device_code=$device_code" \
-d "client_id=$CLIENT_ID")
error=$(echo "$token_resp" | jq -r '.error // empty')
case "$error" in
"") break ;; # success
authorization_pending) continue ;; # still waiting
slow_down) interval=$((interval + 5)) ;; # widen the interval
expired_token)
echo "The code has expired. Please try again." >&2; exit 1 ;;
*) echo "Error: $error" >&2; exit 1 ;;
esac
done
access_token=$(echo "$token_resp" | jq -r .access_token)
echo "Token issued (first 20 chars): $(echo "$access_token" | cut -c1-20)..."
Production code needs three additions on top of this. First, when persisting tokens to disk, use a user-only directory with mode 600 (or the OS keychain). Second, add a refresh-token renewal loop and expiry handling. Third, if possible, generate a DPoP key pair and bind from the issuance step onward. UX for when the user has stepped away — like the expired_token handling and retry guidance — is another part that is easy to forget.
Selection Guide
Decision-making by situation, in a table:
| Question | If yes |
|---|---|
| Can the user log in via a browser on the same device | Authorization Code + PKCE (PAR if needed) |
| Device lacks input/browser, but the user has another device | Device Flow |
| Are the requesting party and the approving user on different channels | CIBA |
| Is only system-level permission needed, with no user involvement | Client Credentials |
| Must a user token be converted to call another service | Token Exchange (RFC 8693) |
| Must damage be contained if a token is stolen | Any flow above + DPoP (or mTLS) |
The 2026 Context — a Device Flow Renaissance for AI Agents and CLIs
The Device Flow is an old mechanism, designed around 2012 for TV apps, yet as of 2026 it is also the fastest-growing flow in adoption. The reasons are clear.
- Standard pattern for CLI/developer tools: with major developer tools, GitHub CLI among them, adopting the device flow as their default login, "show a code in the terminal, approve in the browser" has become a UX developers know well.
- The human approval loop for AI agents: for an agent to access APIs on a user's behalf, human consent has to happen somewhere. Since the agent process has no browser, the device flow's "hand over a code + approve externally" structure is the natural meeting point. In the MCP ecosystem, combinations of OAuth authorization servers (for example Keycloak 26.6 with CIMD support) and agent clients are being built on this pattern.
- Delegation after approval: to narrow the user token the agent received down to per-tool/per-API permissions, combine it with Token Exchange. "Initial consent via device flow → least-privilege per-task tokens via exchange" is the recommended skeleton for agent authentication.
- The explosion of non-human identity: in environments where agents and workloads outnumber humans, the combination of flows that explicitly design the human touchpoint (device flow, CIBA) and key-based constraining (DPoP) is the practical translation of Zero Trust principles.
An old spec becoming the answer to a new problem is not rare in the standards world. But as adoption grows, so do attacks like device code phishing — so start with the mitigations above as your defaults.
Closing Thoughts
To summarize:
- Situations without a redirect are not the exception but the norm. Classify the problem with Device Flow (same user, different device) and CIBA (different party, different channel).
- The Device Flow is simple, but phishing defense must be part of the design. user_code lifetimes, rate limits, and consent-screen warnings are table stakes.
- Start CIBA with poll mode, and do not underestimate the implementation effort on the authentication-device side. Always use binding_message.
- DPoP is token constraining, not a flow. It can be added to any flow, and it is the most cost-effective defense against token theft for public clients.
- Keycloak supports all three mechanisms, so you can declaratively lay down guardrails like "enforce DPoP on sensitive clients" via client policies.
- The authentication skeleton for the AI-agent era: "secure human consent via device flow → delegate least privilege via token exchange → constrain with DPoP."
Flows are just tools. The point is to first clarify "who approves, where, and with what" — and then pick the standard that fits.
References
- RFC 8628 — OAuth 2.0 Device Authorization Grant
- OpenID Connect Client-Initiated Backchannel Authentication (CIBA) Core
- RFC 9449 — OAuth 2.0 Demonstrating Proof of Possession (DPoP)
- RFC 9126 — OAuth 2.0 Pushed Authorization Requests
- RFC 8693 — OAuth 2.0 Token Exchange
- RFC 9700 — Best Current Practice for OAuth 2.0 Security
- OAuth 2.1 draft (draft-ietf-oauth-v2-1)
- FAPI: Client Initiated Backchannel Authentication Profile
- Keycloak Documentation
- Keycloak Release Notes
- Keycloak 26.6.0 Released
- OpenID Connect Core 1.0