> ## Documentation Index
> Fetch the complete documentation index at: https://docs.runlayer.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Agent Accounts: Sender-Constrained Tokens (DPoP)

> Bind agent account tokens to a client-held ES256 key with RFC 9449 DPoP so a leaked token cannot be replayed.

Recipes for minting and using DPoP-bound agent account tokens. Start with [Agent Account Authentication Recipes](/cookbook-agent-accounts) 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

<Warning>
  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.
</Warning>

## Contract at a glance

| Item                          | Value                                                                                                                                       |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Proof algorithm               | `ES256` only (P-256 key), `typ: "dpop+jwt"`, public JWK embedded in the header                                                              |
| Proof claims                  | `jti`, `htm`, `htu`, `iat`; plus `ath` when a token is presented                                                                            |
| `htu`                         | Scheme + host + path of the request, no query or fragment (`$RUNLAYER_URL/api/v1/oauth/token`, `$RUNLAYER_URL/api/v1/proxy/$SERVER_ID/mcp`) |
| Clock skew                    | `iat` accepted within ±60 seconds of server time                                                                                            |
| Replay                        | Each `jti` is single-use per key for 180 seconds                                                                                            |
| Proof size                    | Exactly one `DPoP` header, at most 4096 bytes                                                                                               |
| Grants that accept a proof    | `client_credentials`, `urn:ietf:params:oauth:grant-type:token-exchange`                                                                     |
| Bound token response          | `token_type: "DPoP"`, `cnf.jkt` inside the JWT                                                                                              |
| Presenting a bound token      | `Authorization: DPoP <token>` plus a `DPoP` proof header with `ath`                                                                         |
| Token lifetime and revocation | Unchanged: 1 hour, `POST /api/v1/oauth/revoke` per token                                                                                    |

## Step 1: Generate an ES256 key pair

<CodeGroup>
  ```bash openssl theme={null}
  openssl ecparam -name prime256v1 -genkey -noout -out dpop-key.pem
  chmod 600 dpop-key.pem
  ```

  ```python python theme={null}
  from cryptography.hazmat.primitives import serialization
  from cryptography.hazmat.primitives.asymmetric import ec

  key = ec.generate_private_key(ec.SECP256R1())
  with open("dpop-key.pem", "wb") as f:
      f.write(key.private_bytes(
          serialization.Encoding.PEM,
          serialization.PrivateFormat.PKCS8,
          serialization.NoEncryption(),
      ))
  ```
</CodeGroup>

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).

```python dpop.py theme={null}
import base64, hashlib, json, sys, time, uuid
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature


def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


with open("dpop-key.pem", "rb") as f:
    KEY = serialization.load_pem_private_key(f.read(), password=None)

_nums = KEY.public_key().public_numbers()
JWK = {
    "kty": "EC",
    "crv": "P-256",
    "x": b64url(_nums.x.to_bytes(32, "big")),
    "y": b64url(_nums.y.to_bytes(32, "big")),
}

# RFC 7638 thumbprint: required members only, lexicographic order, no whitespace.
JKT = b64url(hashlib.sha256(json.dumps(
    {k: JWK[k] for k in ("crv", "kty", "x", "y")}, separators=(",", ":")
).encode()).digest())


def dpop_proof(method: str, url: str, access_token: str | None = None) -> str:
    header = {"typ": "dpop+jwt", "alg": "ES256", "jwk": JWK}
    claims = {
        "jti": str(uuid.uuid4()),
        "htm": method.upper(),
        "htu": url.split("?", 1)[0].split("#", 1)[0],
        "iat": int(time.time()),
    }
    if access_token is not None:
        claims["ath"] = b64url(hashlib.sha256(access_token.encode()).digest())
    signing_input = ".".join(
        b64url(json.dumps(part, separators=(",", ":")).encode())
        for part in (header, claims)
    )
    r, s = decode_dss_signature(KEY.sign(signing_input.encode(), ec.ECDSA(hashes.SHA256())))
    return signing_input + "." + b64url(r.to_bytes(32, "big") + s.to_bytes(32, "big"))


if __name__ == "__main__":
    print(dpop_proof(*sys.argv[1:4]))
```

`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.

```bash theme={null}
python3 -c 'import dpop; print(dpop.JKT)'
```

## Step 3: Mint a bound token (client credentials)

Same request as a Bearer mint, plus one `DPoP` header whose `htu` is the token endpoint.

<CodeGroup>
  ```bash curl theme={null}
  PROOF=$(python3 dpop.py POST "$RUNLAYER_URL/api/v1/oauth/token")

  curl -sS -X POST "$RUNLAYER_URL/api/v1/oauth/token" \
    -H "DPoP: $PROOF" \
    --data-urlencode "grant_type=client_credentials" \
    --data-urlencode "client_id=$CLIENT_ID" \
    --data-urlencode "client_secret=$CLIENT_SECRET"
  ```

  ```python python theme={null}
  import os, httpx
  from dpop import dpop_proof

  RUNLAYER_URL = os.environ["RUNLAYER_URL"]
  TOKEN_URL = f"{RUNLAYER_URL}/api/v1/oauth/token"

  resp = httpx.post(
      TOKEN_URL,
      headers={"DPoP": dpop_proof("POST", TOKEN_URL)},
      data={
          "grant_type": "client_credentials",
          "client_id": os.environ["CLIENT_ID"],
          "client_secret": os.environ["CLIENT_SECRET"],
      },
  )
  resp.raise_for_status()
  token = resp.json()
  assert token["token_type"] == "DPoP"
  AGENT_TOKEN = token["access_token"]
  ```
</CodeGroup>

Response:

```json theme={null}
{
  "access_token": "eyJ...",
  "token_type": "DPoP",
  "expires_in": 3600,
  "scope": "..."
}
```

`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).

<CodeGroup>
  ```bash curl theme={null}
  MCP_URL="$RUNLAYER_URL/api/v1/proxy/$SERVER_ID/mcp"
  PROOF=$(python3 dpop.py POST "$MCP_URL" "$AGENT_TOKEN")

  curl -sS -X POST "$MCP_URL" \
    -H "Authorization: DPoP $AGENT_TOKEN" \
    -H "DPoP: $PROOF" \
    -H "Content-Type: application/json" \
    -H "Accept: application/json, text/event-stream" \
    -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
  ```

  ```python python theme={null}
  MCP_URL = f"{RUNLAYER_URL}/api/v1/proxy/{os.environ['SERVER_ID']}/mcp"

  resp = httpx.post(
      MCP_URL,
      headers={
          "Authorization": f"DPoP {AGENT_TOKEN}",
          "DPoP": dpop_proof("POST", MCP_URL, AGENT_TOKEN),
          "Content-Type": "application/json",
          "Accept": "application/json, text/event-stream",
      },
      json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
  )
  resp.raise_for_status()
  ```
</CodeGroup>

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.)

<CodeGroup>
  ```bash curl theme={null}
  PROOF=$(python3 dpop.py POST "$RUNLAYER_URL/api/v1/oauth/token")

  curl -sS -X POST "$RUNLAYER_URL/api/v1/oauth/token" \
    -H "DPoP: $PROOF" \
    --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
    --data-urlencode "client_id=$CLIENT_ID" \
    --data-urlencode "client_secret=$CLIENT_SECRET" \
    --data-urlencode "actor_token=$AGENT_TOKEN" \
    --data-urlencode "actor_token_type=urn:ietf:params:oauth:token-type:access_token" \
    --data-urlencode "subject_token=alice@example.com" \
    --data-urlencode "subject_token_type=urn:runlayer:token-type:user-email"
  ```

  ```python python theme={null}
  resp = httpx.post(
      TOKEN_URL,
      headers={"DPoP": dpop_proof("POST", TOKEN_URL)},
      data={
          "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
          "client_id": os.environ["CLIENT_ID"],
          "client_secret": os.environ["CLIENT_SECRET"],
          "actor_token": AGENT_TOKEN,
          "actor_token_type": "urn:ietf:params:oauth:token-type:access_token",
          "subject_token": "alice@example.com",
          "subject_token_type": "urn:runlayer:token-type:user-email",
      },
  )
  resp.raise_for_status()
  OBO_TOKEN = resp.json()["access_token"]  # token_type == "DPoP"
  ```
</CodeGroup>

Then call the proxy exactly as in Step 4 with `OBO_TOKEN`.

<Note>
  `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.
</Note>

<Warning>
  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.
</Warning>

## 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.

<Warning>
  `require_dpop` is incompatible with [Runlayer Hooks](/runlayer-hooks-sdk) 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.
</Warning>

## 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

| Status | Where                    | Body / header                                                                                       | Meaning                                                                                                                                                                                                                                                                                                                                | Fix                                                                                                                   |
| ------ | ------------------------ | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| 400    | token endpoint           | `{"error": "invalid_dpop_proof", "error_description": "..."}`                                       | Two `DPoP` headers, over 4096 bytes, wrong `alg`/`typ`/JWK, bad signature, `htu`/`htm` mismatch, `iat` outside ±60s, replayed `jti`, bound actor token exchanged without a proof or with a different key, any grant without a proof or an unbound actor token exchanged on an account with `require_dpop`, or replay store unavailable | Regenerate the proof with `htu` = token endpoint; check clock; use the same key for the whole chain                   |
| 400    | token endpoint           | `{"error": "invalid_request", "error_description": "DPoP proofs are not supported for this grant"}` | Proof sent with a grant that does not support binding (`authorization_code`, `refresh_token`, ID-JAG exchange)                                                                                                                                                                                                                         | Drop the `DPoP` header for that grant                                                                                 |
| 400    | token endpoint           | `{"error": "invalid_scope", "error_description": "..."}`                                            | `offline_access` requested on a bound exchange                                                                                                                                                                                                                                                                                         | Remove `offline_access`; re-mint from the actor token instead                                                         |
| 400    | `/api/v1/oauth/register` | `{"detail": "invalid_client_metadata: dpop_bound_access_tokens is not supported; ..."}`             | Dynamic client registration asked for `dpop_bound_access_tokens: true`                                                                                                                                                                                                                                                                 | Not supported for DCR clients; use an agent account                                                                   |
| 400    | proxy                    | `{"detail": "invalid_request: multiple Authorization headers"}`                                     | More than one `Authorization` header on the request                                                                                                                                                                                                                                                                                    | Send exactly one `Authorization` header                                                                               |
| 401    | proxy                    | `WWW-Authenticate: DPoP error="invalid_dpop_proof", error_description="...", algs="ES256"`          | Proof missing, malformed, wrong `htu`/`htm`, missing or wrong `ath`, `iat` skew, replayed `jti`, or replay store unavailable                                                                                                                                                                                                           | New proof per request with `htu` = the exact proxy URL and `ath` over the token you send                              |
| 401    | proxy                    | `WWW-Authenticate: DPoP error="invalid_token", error_description="...", algs="ES256"`               | Unbound token sent as `DPoP`, proof key does not match the token's `cnf.jkt`, or the account has `require_dpop` and the token was sent as `Bearer`                                                                                                                                                                                     | Match scheme to token type; sign with the key that minted the token; re-mint with a proof for `require_dpop` accounts |
| 401    | proxy                    | `WWW-Authenticate: Bearer realm="OAuth", error="invalid_token", ...`                                | Bound token sent as `Bearer` on an account without `require_dpop` (indistinguishable from any other bad Bearer token)                                                                                                                                                                                                                  | Send it as `Authorization: DPoP <token>` with a proof                                                                 |

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](/platform-agent-accounts#credential-rotation)).
2. Revoke live tokens with `POST $RUNLAYER_URL/api/v1/oauth/revoke` per token ([Revoking Tokens](/platform-agent-accounts#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.

<Tip>
  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.
</Tip>
