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
opensslor Python 3 with thecryptographypackageSERVER_IDof an MCP server the account can reach
Contract at a glance
Step 1: Generate an ES256 key pair
Step 2: A proof signer
Save this asdpop.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 oneDPoP header whose htu is the token endpoint.
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 becomesDPoP, 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).
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 samecnf.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.)
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.Requiring DPoP for an account
An admin can turn on Require DPoP in the agent account’s settings dialog (API fieldrequire_dpop, default off). Once on:
- Every mint for the account must carry a proof.
client_credentials, token exchange, andrefresh_tokenrequests without one fail with400 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 withclient_credentialsand a proof, then exchange with the same key. - The proxy rejects the account’s tokens unless they arrive as
Authorization: DPoPwith a proof. A Bearer token minted before the flip gets401withWWW-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.
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.
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
iatmust be within 60 seconds of Runlayer’s clock. Use NTP; do not pre-generate proofs.- Each
jtiis accepted once per key and remembered for 180 seconds. Retry with a fresh proof; a proof is never valid twice. The proxy consumes thejtionly 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
DPoPheader per request, at most 4096 bytes. A P-256 proof with a UUIDjtiis 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:- Rotate the client secret (Credential Rotation).
- Revoke live tokens with
POST $RUNLAYER_URL/api/v1/oauth/revokeper 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 byJKT. - 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 aDPoP 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.