OAuth DPoP for Agent Tool Calls
Bind agent tool tokens to proof keys with exact URI and method checks, replay defenses, nonces, rotation, gateway preservation, and denial receipts.
OAuth DPoP reduces the value of a stolen bearer token by requiring each agent tool call to carry a proof made with the token's bound key.
This guide turns that promise into an exact verifier for method, URI, token hash, time, nonce, replay, gateway routing, rotation, and the authorization decision that still governs the effect.
OAuth DPoP changes the stolen-token threat
A token-theft threat table becomes the first exhibit in the proof verifier. It records token location, key location, attacker capability, proof check, residual risk, and response owner so the authorization owner can challenge how the claim was formed.
Start with a concrete theft path: logs, browser storage, sidecar memory, proxy capture, or a compromised tool client. DPoP helps only when the attacker lacks the private key and the resource server validates the proof instead of accepting the access token as an ordinary bearer credential.
Trace the theft scenario before adding cryptography. A bearer token copied from a log can normally be replayed from another process; a sender-constrained token also demands a proof made by the bound key.
DPoP narrows the stolen artifact's usefulness, but it does not decide whether the original agent may initiate a payment. In the proof verifier, Demonstrating Proof of Possession is the first evidence the authorization owner requests from OAuth DPoP. DPoP reduces replay reach; least-authority policy still controls the effect.
- Key: Hold it narrowly
- Token: Bind the thumbprint
- Proof: Name this call
- Policy: Authorize the effect
Generate a non-exportable task key
Ask the system to restart, rotate, revoke, and attempt migration without private-key export, on purpose. The authorization owner treats the key is committed to configuration or copied between workers as a failed proof verifier branch. Let DPoP proof JWT reframe the OAuth DPoP claim before the authorization owner accepts the proof verifier's answer. Short key lifetime limits damage without turning every tool call into a new authorization ceremony.
Create the asymmetric key inside the narrowest agent runtime that can sign requests and store only its public JWK thumbprint in coordination state. Rotation and process recovery need explicit behavior so operators do not copy private material merely to make durable jobs convenient.
Key creation belongs to the task boundary. A short-lived, non-exportable P-256 private key can live inside the agent worker while its public JWK travels through authorization. Persisting that private material beside general application secrets would widen both the replay window and the number of operators able to impersonate the caller.
The proof boundary follows RFC 9449 DPoP, RFC 9700 OAuth security BCP, and MCP authorization draft. RFC 9449 defines DPoP proof and access-token confirmation, while RFC 9700 supplies the wider OAuth threat model. The MCP draft can establish a protected-resource flow, but resource policy still decides whether a bound caller may execute a tool.
Request a sender-constrained token
Use the outcome of present the token with a proof from a second key and require denial to interrogate a token-binding receipt. The authorization owner revises the proof verifier wherever those two records disagree.
The authorization server must bind the access token to the public-key thumbprint rather than merely observe a proof once. The resource server later compares that confirmation value with the JWK carried by the request proof.
Without the join, the protocol has authentication theater but no sender constraint. OAuth DPoP becomes observable when the proof verifier gives sender-constrained tokens a test the authorization owner can repeat. The token and proof must point to the same key, or neither binding is meaningful.
The authorization server must bind the access token to the DPoP public key and the client must confirm the returned token type and confirmation claim. A proof sent only to the token endpoint does not make an unrelated bearer token safe at the resource server.
| Binding | Proof field | Server check | Failure |
|---|---|---|---|
| Method | htm | Exact verb | Deny |
| URI | htu | External target | Deny |
| Token | ath | Hash | Deny |
| Replay | jti | Atomic cache | Deny |
Construct the proof JWT exactly
The authorization owner cannot accept a canonical proof builder without algorithm, JWK thumbprint, method, URI, issued-at, JTI, access-token hash, and nonce. In the proof verifier, that omission counts as missing evidence rather than polish. Sign a fresh proof containing the public JWK and required header and claims, then bind it to the uppercase HTTP method and effective target URI. Add access-token hash for resource requests and retain issued-at and unique identifier limits as protocol checks, not optional telemetry.
This proof verifier section turns agent tool authorization into the authorization owner's counterexample for OAuth DPoP. Exactness is a feature here: a proof for a neighboring request is deliberately invalid.
A DPoP proof names one HTTP event. Its header identifies the proof type, algorithm, and public key; its claims bind method, normalized target URI, issued time, unique identifier, and sometimes nonce and access-token hash. Changing any one of those facts should make the signature useless for the altered call.
Verify before parsing tool arguments
Push the edge by attempting to send invalid signatures, stale times, wrong hashes, malformed JWKs, and repeated JTIs. The proof verifier fails when tool logic runs before proof validation completes, leaving the next correction with the authorization owner.
Verify the proof before parsing expensive tool arguments or beginning side effects. Signature, key binding, algorithm, method, URI, token hash, freshness, nonce, and replay checks form a cheap perimeter. Product authorization happens afterward, using the verified caller and the requested business action as separate inputs.
Authenticate the request and DPoP binding before expensive body parsing or tool dispatch. The verifier should check signature, allowed algorithm, public key, method, URI, time window, token hash, nonce when required, and replay uniqueness before passing an identity downstream. The next OAuth DPoP move belongs to the authorization owner once Demonstrating Proof of Possession changes the proof verifier's result. Rejecting early also keeps attacker-controlled bodies away from expensive parsers and downstream tools.
Preserve URI meaning through gateways
Reverse proxies often terminate TLS, rewrite paths, or expose internal hosts that differ from the public request URI. Configure one trusted external-URI derivation rule and reject untrusted forwarding headers, because loose canonicalization can turn htu validation into a bypass or a production outage. Question OAuth DPoP through DPoP proof JWT; the proof verifier should expose the assumption the authorization owner must own. Write the external-target rule once and test it at every proxy hop.
First reproduce replay proofs across aliases, internal hosts, encoded paths, and redirect boundaries; then write the mismatch into a gateway URI truth table. This order gives the proof verifier an inspectable debugging trail for the authorization owner.
Gateways complicate htu because the verifier may see an internal scheme, host, or path that the caller never signed. Establish one trusted reconstruction rule from narrowly accepted forwarding metadata, then compare against the external request target. Permissive normalization creates a corridor where two different calls appear equivalent.
- 1Key
Hold it narrowly
- 2Token
Bind the thumbprint
- 3Proof
Name this call
- 4Policy
Authorize the effect
Use nonce and replay caches deliberately
A replay cache changes a stateless signature check into a distributed consistency problem. Store the proof identifier atomically for at least the acceptance window, scope it to the relevant key, and bound memory with expiry.
Nonces help a server recover freshness when clocks or risk signals demand another round trip. At this step, the authorization owner reads sender-constrained tokens as an explicit OAuth DPoP choice recorded by the proof verifier. Atomic replay storage matters more than a beautifully signed duplicate request.
Preserve thumbprint, JTI, issued-at, nonce, cache insertion, expiry, duplicate result, and region alongside an atomic replay state machine. The proof verifier is auditable only when the authorization owner can read that pair without private context.
A bounded replay cache should retain accepted JTI and key pairs for the usable proof window, while server nonces add freshness where risk or deployment requires them. Define concurrent duplicate behavior so two workers cannot both accept the same proof before either records it.
Join proof possession to tool authority
After DPoP succeeds, validate token audience, scopes or authorization details, current user and workload identity, tool schema, policy, and effect limits. Proof of possession says which key sent the request; it does not say that paying an invoice or exporting records is allowed now.
Make the adverse case use a valid read-only token to attempt a payment tool. When a cryptographically valid proof skips product authorization, the authorization owner redraws the limit exposed by the proof verifier. Without a passing agent tool authorization case, the proof verifier gives the authorization owner no complete evidence for OAuth DPoP. A valid proof never upgrades an insufficient business permission.
Possession and permission answer different questions. The proof says this request came from the holder of a particular private key; the policy engine still evaluates audience, scope, RAR details, tenant, task, amount, and human approval. Combining those decisions makes incident analysis needlessly opaque.
The authorization path connects outward to MCP OAuth audience validation, OAuth RAR for least-authority agents, AI agent workload identity, and build an OAuth PKCE client. Audience validation, RAR, workload identity, and PKCE answer different questions about recipient, authority, process, and public-client exchange. The final verifier should correlate them without treating key possession as business permission.
Operate rotation and incident response
The authorization owner closes the OAuth DPoP release only after the proof verifier demonstrates Demonstrating Proof of Possession. Operational evidence should identify the join while keeping reusable credentials out of telemetry.
Rotation planning begins before a key leaks. Define task-key lifetime, token refresh behavior, replay-store eviction, clock skew, gateway configuration, and emergency revocation. During an incident, operators need to identify which proof key, token, task, and effect were joined without dumping raw authorization material into another log.
Log public thumbprints and decisions without storing private keys or complete tokens, then connect revocation, worker quarantine, and token replacement to one incident path. Durable jobs should fail closed or obtain a newly authorized binding rather than silently sharing the retired key.
For release, a DPoP incident drill must agree with a run that can revoke one worker during an in-flight multi-step agent task. The proof verifier stays provisional until the authorization owner explains any difference.
The runnable model accepts one correctly bound proof, rejects its replay, and rejects a proof for another target.
Runnable artifact — dpop-proof-binding.test.mjs
import assert from "node:assert/strict";import {createHash,webcrypto} from "node:crypto";
const b64=x=>Buffer.from(x).toString("base64url"),json=x=>b64(JSON.stringify(x));
const {privateKey,publicKey}=await webcrypto.subtle.generateKey({name:"ECDSA",namedCurve:"P-256"},true,["sign","verify"]);const jwk=await webcrypto.subtle.exportKey("jwk",publicKey);
const token="access-token-17",ath=b64(createHash("sha256").update(token).digest()),now=Math.floor(Date.now()/1000);
async function sign(claims){const head=json({typ:"dpop+jwt",alg:"ES256",jwk}),body=json(claims),input=head+"."+body;const sig=await webcrypto.subtle.sign({name:"ECDSA",hash:"SHA-256"},privateKey,Buffer.from(input));return input+"."+b64(sig)}
const seen=new Set();async function verify(jwt,request){const [h,p,s]=jwt.split("."),claims=JSON.parse(Buffer.from(p,"base64url"));const valid=await webcrypto.subtle.verify({name:"ECDSA",hash:"SHA-256"},publicKey,Buffer.from(s,"base64url"),Buffer.from(h+"."+p));if(!valid||claims.htm!==request.method||claims.htu!==request.url||claims.ath!==request.ath||claims.nonce!==request.nonce||Math.abs(now-claims.iat)>5||seen.has(claims.jti))return false;seen.add(claims.jti);return true}
const request={method:"POST",url:"https://tools.example/pay",ath,nonce:"n-7"},base={htm:request.method,htu:request.url,ath,nonce:request.nonce,iat:now,jti:"p-1"};
assert.equal(await verify(await sign(base),request),true);assert.equal(await verify(await sign(base),request),false);
for(const patch of [{jti:"p-htu",htu:"https://tools.example/read"},{jti:"p-htm",htm:"GET"},{jti:"p-ath",ath:"wrong"},{jti:"p-iat",iat:now-30},{jti:"p-nonce",nonce:"wrong"}])assert.equal(await verify(await sign({...base,...patch}),request),false);
console.log("PASS: DPoP proof is bound to one call");
Run node dpop-proof-binding.test.mjs. Expected receipt: PASS: DPoP proof is bound to one call.
Use OAuth DPoP to constrain token replay at the exact resource call, while keeping audience, scope, typed authority, and product policy as separate gates. Reopen the verifier when proxy topology, canonical URI rules, nonce policy, or authorization specifications change, and retain denial fixtures for every binding field.