Skip to main content
Recipes for minting and using DPoP-bound agent account tokens. Start with Agent Account Authentication Recipes if you have not used client_credentials or token exchange before.

What DPoP gives you

A normal (Bearer) agent token works for anyone who holds it. A DPoP-bound token only works together with a fresh proof signed by the private key you generated. Runlayer records the key’s thumbprint in the token (cnf.jkt) and, on every call, checks that the proof was signed by that same key, targets that exact URL and method, and hashes that exact token. A token copied out of a log, a trace, or an upstream server is useless without the key. DPoP is opt-in per request: send a proof and you get a bound token; send nothing and you get a Bearer token exactly as before.

Prerequisites

  • A Runlayer instance (RUNLAYER_URL, the same origin your admin configured as the app URL; proofs are compared against it, not against whatever host you happen to dial)
  • An agent account with Client ID and Client Secret
  • openssl or Python 3 with the cryptography package
  • SERVER_ID of an MCP server the account can reach
Bound tokens are not usable through the Anthropic MCP tunnel or any path where the public origin differs from RUNLAYER_URL: the proof’s htu will not match and the call fails with invalid_dpop_proof. Keep those callers on Bearer.

Contract at a glance

Step 1: Generate an ES256 key pair

Keep the private key with the client secret. Runlayer never sees it; it only sees the public JWK inside each proof.

Step 2: A proof signer

Save this as dpop.py. It is plain Python plus cryptography, no SDK. The same file is used by the curl recipes below (python3 dpop.py METHOD URL [ACCESS_TOKEN] prints one proof).
dpop.py
JKT is the thumbprint Runlayer will put in cnf.jkt. Print it once and keep it: audit events show its first 8 characters (jkt_prefix), and Runlayer’s token registry records the full value on every token bound to this key.

Step 3: Mint a bound token (client credentials)

Same request as a Bearer mint, plus one DPoP header whose htu is the token endpoint.
Response:
token_type is DPoP, not Bearer. Decode the JWT and you will find "cnf": {"jkt": "<your JKT>"}. Every other form parameter (resource, scope) behaves as it does for Bearer.

Step 4: Call an MCP server with the bound token

Two headers change: the scheme becomes DPoP, and a fresh proof carries ath, the SHA-256 of the token you are presenting. Generate a new proof for every request; reusing one fails with invalid_dpop_proof (replay).
Presenting a bound token as Authorization: Bearer is treated as unauthenticated: you get the ordinary 401 Bearer challenge (error="invalid_token"), not a DPoP one. Presenting an unbound Bearer token with the DPoP scheme fails with a 401 DPoP challenge (error="invalid_token"). The two schemes do not mix.

Step 5: On-behalf-of (token exchange) with the same key

RFC 8693 exchange works unchanged, with two additions: the request carries a proof for the token endpoint, and that proof must be signed by the same key that bound the actor token. The OBO token inherits the same cnf.jkt, so one key serves the whole chain. A bound actor token exchanged without a proof, or with a proof from another key, fails with 400 invalid_dpop_proof. (An unbound Bearer actor token may be exchanged with a proof; the OBO token is then bound to that key. Accounts with require_dpop refuse this, see below.)
Then call the proxy exactly as in Step 4 with OBO_TOKEN.
scope=offline_access is rejected with 400 invalid_scope on a bound exchange. A refresh token would outlive the key binding, so bound OBO tokens are re-minted from the actor token instead. Bearer exchanges keep their refresh tokens.
If your client caches tokens, key the cache by issuer, agent account, delegator, resource, scopes, token type, and DPoP key thumbprint. An application that shares one DPoP key across users and caches by agent or server alone can hand one user another user’s OBO token.

Requiring DPoP for an account

An admin can turn on Require DPoP in the agent account’s settings dialog (API field require_dpop, default off). Once on:
  • Every mint for the account must carry a proof. client_credentials, token exchange, and refresh_token requests without one fail with 400 invalid_dpop_proof (agent account requires DPoP (require_dpop); present a DPoP proof). On-behalf-of refresh tokens issued before the flip therefore stop refreshing; re-mint with a proof.
  • An unbound actor token minted before the flip cannot be exchanged, even with a proof: 400 invalid_dpop_proof (agent account requires DPoP (require_dpop); actor_token is not bound). Otherwise whoever holds the old token could bind an OBO token to their own key. Re-mint the actor token with client_credentials and a proof, then exchange with the same key.
  • The proxy rejects the account’s tokens unless they arrive as Authorization: DPoP with a proof. A Bearer token minted before the flip gets 401 with WWW-Authenticate: DPoP error="invalid_token", error_description="invalid_token: agent account requires DPoP (require_dpop)", algs="ES256", ....
  • Nothing changes for callers that already send proofs.
The dialog shows Last 30 days: N unbound, M DPoP-bound tokens for the account so you can see who would break; flagged accounts carry a DPoP badge in the list. Turning the flag off is always allowed.
require_dpop is incompatible with Runlayer Hooks and hosted agent runs: those paths cannot sign proofs. Runlayer refuses to turn the flag on (409 Cannot require DPoP while proof-less consumers use this account: ..., naming the blockers) while the account has a hooks session seen in the last 7 days or is linked to a hosted agent, and a hosted agent linked to a flagged account cannot start. Give hook-driven workloads their own agent account and keep it on Bearer.

Discovery

GET $RUNLAYER_URL/.well-known/oauth-authorization-server (and the per-resource /.well-known/oauth-protected-resource/... documents) list dpop_signing_alg_values_supported: ["ES256"] only once your operator has enabled advertisement; the same switch appends DPoP algs="ES256" after the Bearer challenge in WWW-Authenticate on unauthenticated 401s. The switch is off by default so clients that read metadata keep using Bearer. It does not gate enforcement: proofs are verified and bound tokens are checked whether or not the field is advertised.

Errors

Every WWW-Authenticate header also carries resource_metadata so metadata-driven clients can still discover the resource.

Clock, replay, and size rules

  • iat must be within 60 seconds of Runlayer’s clock. Use NTP; do not pre-generate proofs.
  • Each jti is accepted once per key and remembered for 180 seconds. Retry with a fresh proof; a proof is never valid twice. The proxy consumes the jti only when the request is accepted; the token endpoint consumes it once the client is authenticated, so a rejected proof or bad credentials never burn it.
  • One DPoP header per request, at most 4096 bytes. A P-256 proof with a UUID jti is well under 1 KB.

Key compromise response

Rotating the key is free: generate a new pair and mint again; old tokens stop working when they expire (1 hour). If the key and the client secret both leaked, also:
  1. Rotate the client secret (Credential Rotation).
  2. Revoke live tokens with POST $RUNLAYER_URL/api/v1/oauth/revoke per token (Revoking Tokens). There is no revoke-by-thumbprint API yet; Runlayer’s token registry records the thumbprint of every bound token, so an operator can find the affected tokens by JKT.
  3. Disable the account if in doubt; a disabled account cannot mint and its tokens stop resolving.

Bearer keeps working

Nothing here changes existing integrations. Requests without a DPoP header mint Bearer tokens; Bearer tokens are accepted everywhere they are today. The Runlayer Python and TypeScript SDKs currently reject token_type: "DPoP" responses; built-in signers are a follow-up, so use the raw HTTP recipes above until then.
Adopt per account: create a dedicated agent account for the workload that needs sender-constrained tokens, test it with the recipes above, then turn on require_dpop for that account only.