OAuth flows for agents: PKCE, Device, CIBA, Token Exchange, RAR, DPoP
Pick the right grant per scenario and implement token exchange, CIBA approvals, and DPoP binding.
Cross-ref (Microsoft stack): Microsoft Entra Agent ID takes a deliberately restrictive subset of these flows — no
/authorize, no public clients, no device code for agent principals. It supports only three grants:client_credentials(autonomous),jwt-bearer(OBO), andjwt-bearervia FIC chain (agent user). See Lesson 10.3 for the Microsoft-specific flow set and why interactive flows are blocked for agents.
Why this matters
Generic OAuth tutorials assume "a user clicks a button in a browser". Agent flows often have no browser, no synchronous user, or need per-action user approval out-of-band. This lesson gives you the modern flow library — pick the right one per agent + per action.
Learning objectives
- Implement Authorization Code + PKCE for the user-facing app.
- Use the Device Authorization Grant for headless agent installation.
- Use CIBA (Client-Initiated Backchannel Auth) for agent-driven user approval.
- Compose Token Exchange (RFC 8693) for delegation chains.
- Apply Rich Authorization Requests (RFC 9396) for fine-grained, contextual consent.
- Apply DPoP (RFC 9449) to prevent token theft.
1. Authorization Code + PKCE (the baseline)
The default for any flow where a user is present in a browser. Already covered in 0.1. Two important reminders for agent setups:
- Request
offline_accessto receive a refresh token. - Store the refresh token server-side in a vault, tied to the user. Never expose to the agent runtime unless the runtime is itself a secure server.
# (see lesson 0.1 hello_oauth.py for the full code)
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT,
"client_id": "helpdesk-app",
"client_secret": CLIENT_SECRET,
"code_verifier": verifier,
}2. Device Authorization Grant (RFC 8628)
For agents installed via a CLI or running on a device without a browser (Raspberry Pi, edge gateway, TV, headless server). Familiar from gh auth login and AWS SSO.
import requests, time
# 1. Agent asks IdP for a device + user code
r = requests.post(f"{ISSUER}/protocol/openid-connect/auth/device",
data={"client_id": "agent.helpdesk-v1",
"scope": "openid offline_access tickets:read"},
verify=False).json()
print(f"Open {r['verification_uri_complete']} and approve as a human")
device_code = r["device_code"]
interval = r["interval"]
# 2. Poll for completion
while True:
time.sleep(interval)
t = requests.post(f"{ISSUER}/protocol/openid-connect/token", data={
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_code,
"client_id": "agent.helpdesk-v1",
}, verify=False).json()
if "access_token" in t: break
if t.get("error") not in ("authorization_pending","slow_down"):
raise RuntimeError(t)
print("got tokens:", t)Best for one-time agent installation. After approval you have a refresh token; subsequent runs don't need a browser. Pair with DPoP for the refresh token's safety.
3. CIBA (Client-Initiated Backchannel Authentication)
OpenID Connect CIBA is the protocol for agent-initiated user approval. The agent asks the IdP to authenticate the user; the IdP pings the user's authenticator app / push channel; the user approves; the agent receives the token.
No browser needed for the agent. No assumption that the user is in front of the device.
Flow
- Agent → IdP
/bc-authorize: "I want to perform action X for useralice, scopepayments:write $200". - IdP sends push to Alice's phone (Authy/Okta Verify/etc.). The push shows the action context ("Helpdesk Agent wants to refund $200 to order #1234").
- Alice approves (or rejects).
- Agent polls
/tokenwithgrant_type=urn:openid:params:grant-type:ciba. - On approval, gets a narrow, action-bound token.
Why it matters for agents
For high-stakes actions, you want explicit, contextual user consent at the moment of action — not "once at install time forever". CIBA bakes that into the protocol.
import requests, time
r = requests.post(f"{ISSUER}/protocol/openid-connect/ext/ciba/auth",
auth=("agent.helpdesk-v1", CLIENT_SECRET),
data={
"login_hint": "alice@acme.com",
"scope": "openid payments:write",
"binding_message": "Refund $200 to order #1234?", # shown to the user
"request_context": "{\"order_id\":\"1234\",\"amount\":200,\"currency\":\"USD\"}",
}, verify=False).json()
auth_req_id = r["auth_req_id"]
interval = r.get("interval", 5)
while True:
time.sleep(interval)
t = requests.post(f"{ISSUER}/protocol/openid-connect/token",
auth=("agent.helpdesk-v1", CLIENT_SECRET),
data={
"grant_type": "urn:openid:params:grant-type:ciba",
"auth_req_id": auth_req_id,
}, verify=False).json()
if "access_token" in t: break
if t.get("error") not in ("authorization_pending","slow_down"):
raise RuntimeError(t)Keycloak, Auth0, ForgeRock, Curity all support CIBA today. Pair with FAPI 2.0 + DPoP for regulated workloads.
Binding message is crucial: it's what the user actually sees on their device. Treat it as user-facing security copy, not a debug string.
4. Token Exchange (RFC 8693)
Already used in lesson 2.1 for OBO. Two other valuable patterns:
Down-scoping mid-flight
The agent has a broad token; it's about to call a low-trust tool. Exchange the broad token for a narrow one:
narrow = requests.post(f"{ISSUER}/protocol/openid-connect/token",
auth=("agent.helpdesk-v1", CLIENT_SECRET),
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": broad_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"audience": "third-party-tool",
"scope": "search:read", # narrower than `broad_token` had
}, verify=False).json()Hand the narrow token to the tool. Even if the tool is compromised, blast radius is limited.
Token-to-token translation across IdPs
Agent has a SPIFFE JWT; needs a Salesforce-issued OAuth token. Exchange via Salesforce's IdP using your SPIFFE JWT as subject_token. Federate identity instead of replicating users + secrets.
5. Rich Authorization Requests (RFC 9396)
Scopes alone can't express "spend up to $200 on this specific order between now and tomorrow". RAR adds a structured authorization_details parameter:
auth_details = [{
"type": "payment_initiation",
"actions": ["initiate"],
"locations": ["https://payments.acme.com/api"],
"instructedAmount": {"currency": "USD", "amount": "200.00"},
"creditorAccount": {"iban": "DE89..."},
"remittanceInformation": "Refund order 1234",
"expiresAt": "2026-06-18T00:00:00Z"
}]
r = requests.post(f"{ISSUER}/protocol/openid-connect/auth",
data={
"client_id": "agent.helpdesk-v1",
"redirect_uri": REDIRECT,
"response_type": "code",
"scope": "openid",
"authorization_details": json.dumps(auth_details),
}, verify=False)The resulting access token contains an authorization_details claim that the resource server enforces. Used heavily in Open Banking (PSD2) and increasingly for agent payments + writes.
CIBA + RAR is the gold standard for high-stakes agent actions: user sees the exact transaction, agent gets a token only good for that transaction.
6. DPoP (Demonstrating Proof of Possession, RFC 9449)
A bearer token is whoever has it. DPoP binds the token to a key the client holds, so a stolen token alone is useless.
How it works
- Agent generates an EC or RSA keypair at startup.
- When calling token endpoint, sends
DPoPheader — a short JWT signed with the agent's private key, includinghtm(method),htu(URL),iat,jti. - IdP issues a DPoP-bound access token (with
cnf.jkt = SHA-256(public key)). - Every API call from the agent includes a fresh DPoP header for that request URL + method.
- Resource server checks: DPoP
jktmatches the token'scnf.jkt,htu+htmmatch the request,iatis recent,jtinot replayed.
Why for agents
Refresh tokens for agents live a long time and call many tools. A leaked refresh token without DPoP = takeover. With DPoP, attacker needs the agent's private key (in HSM/TPM/SPIRE — much harder).
Implementation in Python
import jwt, time, secrets, hashlib, base64
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
key = ec.generate_private_key(ec.SECP256R1())
public_jwk = jwt.algorithms.ECAlgorithm.to_jwk(key.public_key(), as_dict=True)
def dpop_header(method: str, url: str, ath: str | None = None) -> str:
payload = {"htm": method, "htu": url, "iat": int(time.time()), "jti": secrets.token_urlsafe(16)}
if ath:
payload["ath"] = base64.urlsafe_b64encode(hashlib.sha256(ath.encode()).digest()).rstrip(b"=").decode()
return jwt.encode(payload, key, algorithm="ES256",
headers={"typ": "dpop+jwt", "jwk": public_jwk})
# At /token:
t = requests.post(TOKEN_URL,
headers={"DPoP": dpop_header("POST", TOKEN_URL)},
data={"grant_type": "client_credentials", "client_id":"...", "client_secret":"..."},
verify=False).json()
# At /api/tickets:
access = t["access_token"]
requests.get("https://api.acme.com/tickets",
headers={
"Authorization": f"DPoP {access}",
"DPoP": dpop_header("GET", "https://api.acme.com/tickets", ath=access),
})Production tip: rotate the DPoP key periodically; keep it inside an HSM / KMS / SPIFFE-managed key store.
7. PAR (Pushed Authorization Requests, RFC 9126)
Long authorization_details and detailed RAR claims don't fit nicely in URL query strings. With PAR, the client POSTs the authorisation request to the IdP first, receives a request_uri, then redirects the user to /authorize?request_uri=.... Cleaner, more secure (no params in browser history / referrers).
Required by FAPI 2.0; should be your default for any RAR-using flow.
8. Composition cheatsheet
| Scenario | Recommended composition |
|---|---|
| Web app login | Auth Code + PKCE |
| CLI agent install | Device Authorization Grant + DPoP |
| Agent acting for logged-in user | Auth Code + PKCE → Token Exchange (OBO) → DPoP-bound |
| Agent runs while user offline, sensitive action | CIBA + RAR + PAR + DPoP |
| Agent runs while user offline, low-risk action | Stored refresh token + OBO + DPoP |
| Service agent (no user) | Workload identity → JWT Bearer Grant + DPoP |
| Sub-agent delegation | Token Exchange chain (act claim) + DPoP keys per agent |
9. Hands-on lab (4 h)
- Implement Device Authorization Grant for a CLI that installs
agent.helpdesk-v1. On success, store the refresh token in Vault. - Implement CIBA for a sensitive
refund_order(order_id, amount)action. Use the binding message to display the amount + order on the user's phone (use Keycloak's mobile-push authenticator or simulate via console). - Add RAR: encode
{type: refund, order_id, amount}inauthorization_details. Enforce onpayments-apithat the token's RAR matches the actual API call args. - Wrap all calls with DPoP. Verify that a token replayed without the matching key is rejected.
- Add PAR. Verify the redirect URL no longer contains the RAR JSON.
Acceptance: every step succeeds + an attacker who copies tokens from your logs cannot misuse them.
10. Common pitfalls
- CIBA without a meaningful
binding_message→ users approve blindly (defeating the protocol). - Refresh token rotation disabled → stolen refresh = forever.
- DPoP without
jtireplay cache → replay attacks slip through. - RAR not enforced at the resource server — token says one thing, API blindly does another.
- PAR
request_urireused across users. - Device flow polled too aggressively → IdP rate-limits and you get
slow_down.
11. Self-check
- CIBA in one sentence.
- RAR vs scopes.
- DPoP's
htuclaim — why? - When PAR is required.
- Why Token Exchange beats impersonation.
12. References
- RFC 8628 (Device Authorization Grant).
- RFC 8693 (Token Exchange).
- RFC 9126 (PAR).
- RFC 9396 (RAR).
- RFC 9449 (DPoP).
- OpenID Connect CIBA Core 1.0.
- FAPI 2.0 (Financial-grade API).
- Keycloak + Auth0 + Curity CIBA / DPoP docs.
- Open Banking UK technical specs (gold-standard real-world RAR).
Sign in to save your progress and earn badges.