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.

🔑 Module 3 9 min read Not started

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), and jwt-bearer via 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

  1. Implement Authorization Code + PKCE for the user-facing app.
  2. Use the Device Authorization Grant for headless agent installation.
  3. Use CIBA (Client-Initiated Backchannel Auth) for agent-driven user approval.
  4. Compose Token Exchange (RFC 8693) for delegation chains.
  5. Apply Rich Authorization Requests (RFC 9396) for fine-grained, contextual consent.
  6. 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_access to 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.
python
# (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.

python
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

  1. Agent → IdP /bc-authorize: "I want to perform action X for user alice, scope payments:write $200".
  2. 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").
  3. Alice approves (or rejects).
  4. Agent polls /token with grant_type=urn:openid:params:grant-type:ciba.
  5. 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.

python
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:

python
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:

python
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

  1. Agent generates an EC or RSA keypair at startup.
  2. When calling token endpoint, sends DPoP header — a short JWT signed with the agent's private key, including htm (method), htu (URL), iat, jti.
  3. IdP issues a DPoP-bound access token (with cnf.jkt = SHA-256(public key)).
  4. Every API call from the agent includes a fresh DPoP header for that request URL + method.
  5. Resource server checks: DPoP jkt matches the token's cnf.jkt, htu + htm match the request, iat is recent, jti not 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

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

ScenarioRecommended composition
Web app loginAuth Code + PKCE
CLI agent installDevice Authorization Grant + DPoP
Agent acting for logged-in userAuth Code + PKCE → Token Exchange (OBO) → DPoP-bound
Agent runs while user offline, sensitive actionCIBA + RAR + PAR + DPoP
Agent runs while user offline, low-risk actionStored refresh token + OBO + DPoP
Service agent (no user)Workload identity → JWT Bearer Grant + DPoP
Sub-agent delegationToken Exchange chain (act claim) + DPoP keys per agent

9. Hands-on lab (4 h)

  1. Implement Device Authorization Grant for a CLI that installs agent.helpdesk-v1. On success, store the refresh token in Vault.
  2. 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).
  3. Add RAR: encode {type: refund, order_id, amount} in authorization_details. Enforce on payments-api that the token's RAR matches the actual API call args.
  4. Wrap all calls with DPoP. Verify that a token replayed without the matching key is rejected.
  5. 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

  1. CIBA without a meaningful binding_message → users approve blindly (defeating the protocol).
  2. Refresh token rotation disabled → stolen refresh = forever.
  3. DPoP without jti replay cache → replay attacks slip through.
  4. RAR not enforced at the resource server — token says one thing, API blindly does another.
  5. PAR request_uri reused across users.
  6. Device flow polled too aggressively → IdP rate-limits and you get slow_down.

11. Self-check

  1. CIBA in one sentence.
  2. RAR vs scopes.
  3. DPoP's htu claim — why?
  4. When PAR is required.
  5. 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.