Sender-constrained tokens with DPoP
Bind session tokens to a key the agent holds in memory (OAuth DPoP, RFC 9449), so a copied token is useless, with the Go SDK or your own client.
A session token is a bearer token: whoever holds a copy can use it until it expires. Copies leak from logs, token files, crash dumps or any process that can read the agent's environment. DPoP (OAuth Demonstrating Proof of Possession, RFC 9449) binds the token to a key the agent generates and keeps in memory. The gateway then accepts the token only together with a fresh proof signed by that key.
MAQPNA supports Ed25519 (EdDSA) and P-256 (ES256) keys. DPoP is opt-in per agent; dpop.require makes it mandatory at a gateway.
sequenceDiagram
participant A as Agent (key in memory)
participant B as Identity broker
participant G as Gateway
A->>A: generate Ed25519 key
A->>B: POST /v1/token/bind<br/>Authorization: Bearer <session token><br/>DPoP: proof
B-->>A: same token + cnf.jkt (tokenType DPoP)
A->>G: POST /mcp/echo<br/>Authorization: DPoP <bound token><br/>DPoP: fresh proof
G->>G: check proof: key = cnf.jkt, htm, htu, iat, ath, jti not seen
G-->>A: tool result (ledger records dpopJkt)
Note over G: bound token as Bearer, replayed or stale proof → 401
When to use it#
- Agents run where you cannot deliver X.509-SVIDs (laptops, VMs, desktop agents). With SPIRE in a cluster, see Workload identity with SPIRE.
- You want a stolen token file to be worthless.
How a token gets bound#
A bound token carries the RFC 7638 thumbprint of the agent's public key in its cnf claim:
{"sub": "spiffe://maqpna.local/ns/team-a/agent/coder/session/s1", "cnf": {"jkt": "g7ZFaoLY8xVwKGXc3BPYhobpgb-FxBtV4CyKiprG1mU"}}
There are two ways to get one:
- Mint it bound.
POST /v1/tokenat the identity broker with"dpopJkt": "<thumbprint>", when the minter already knows the agent's public key. The response has"tokenType": "DPoP". This has no window for a thief. - Bind the session token. The agent calls
POST /v1/token/bindwithAuthorization: Bearer <session token>and a proof over it. The broker returns the same token (same subject, scopes, expiry andjti, so revoking thejtirevokes both) pluscnf.jkt. A token binds to one key; another key gets409.
Every request then sends Authorization: DPoP <token> and a DPoP header: a JWT with typ: dpop+jwt, the public key in the header (jwk), and claims htm (method), htu (URL without query), iat, a unique jti and ath (base64url SHA-256 of the token).
Prerequisites#
- A local MAQPNA (
maqpna dev up) or an installation whose identity broker the agent can reach. - For the Go SDK: the MAQPNA Go SDK (Go SDK).
Step 1: Make DPoP mandatory (optional)#
To refuse unbound tokens, add the dpop block to the gateway configuration. Locally:
echo '{"dpop": {"require": true, "maxAgeSeconds": 60}}' > dpop.json
maqpna dev up --gateway-config dpop.json
In a cluster, set the same block under gateway.config.dpop in the Helm values. Set publicURL (for example https://gateway.example.eu) when a TLS-terminating proxy sits in front of the gateway, because proofs name the URL the agent used. It defaults to identity.resourceBaseURL, else the scheme and Host of each request.
Without require, bound tokens are still checked, and unbound tokens sent as Bearer are accepted.
Step 2: Use DPoP from your agent#
c, err := maqpna.NewFromEnv(ctx, maqpna.WithDPoP(nil)) // nil: generate an Ed25519 key in memory
if err != nil {
log.Fatal(err)
}
res, err := c.CallTool(ctx, "echo", "echo", map[string]any{"text": "bound"})
Instead of WithDPoP, you can set MAQPNA_DPOP=1. The SDK binds the session token at MAQPNA_IDENTITY_URL on first use, and again whenever the token file changes. For model calls through another client, use c.HTTPClient() with c.OpenAIHeaders(ctx).
# pip install cryptography requests
import base64, hashlib, json, os, secrets, time
import requests
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
key = Ed25519PrivateKey.generate() # keep it in memory only
x = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
jwk = {"kty": "OKP", "crv": "Ed25519", "x": b64(x)}
def proof(method: str, url: str, token: str) -> str:
header = {"typ": "dpop+jwt", "alg": "EdDSA", "jwk": jwk}
claims = {"jti": secrets.token_hex(16), "htm": method, "htu": url.split("?")[0],
"iat": int(time.time()), "ath": b64(hashlib.sha256(token.encode()).digest())}
signing_input = b64(json.dumps(header).encode()) + "." + b64(json.dumps(claims).encode())
return signing_input + "." + b64(key.sign(signing_input.encode()))
broker = os.environ.get("MAQPNA_IDENTITY_URL", "http://127.0.0.1:8081")
gateway = os.environ["MAQPNA_GATEWAY_URL"]
token = os.environ["MAQPNA_TOKEN"]
bind_url = broker + "/v1/token/bind"
r = requests.post(bind_url, headers={"Authorization": "Bearer " + token, "DPoP": proof("POST", bind_url, token)})
r.raise_for_status()
bound = r.json()["token"] # tokenType "DPoP", jkt = key thumbprint
mcp_url = gateway + "/mcp/echo"
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "echo", "arguments": {"text": "bound"}}}
r = requests.post(mcp_url, json=body, headers={
"Authorization": "DPoP " + bound, "DPoP": proof("POST", mcp_url, bound),
"Accept": "application/json, text/event-stream", "MCP-Protocol-Version": "2025-06-18"})
print(r.status_code, r.text)
Run it with maqpna dev run -- python dpop_agent.py. A new proof is needed for every request: each jti is accepted once.
The Python and TypeScript SDKs do not support DPoP yet; the Python example builds proofs by hand.
Verify#
-
The call succeeds. Its audit record carries
dpopJktinext:eval "$(maqpna dev env)" # loads the local admin token maqpna audit tail --gateway http://127.0.0.1:8080 --tail 1 -o json -
Send the bound token as
Bearer, or reuse a proof: the gateway answers401. - Scrape
/metricsand readmaqpna_gateway_dpop_requests_total{outcome=...}.
What the gateway checks#
| Request | Result |
|---|---|
Bound token, DPoP scheme, valid proof by its key |
Accepted; ledger records carry dpopJkt |
Bound token as Bearer |
401, reason dpop_bearer |
| Missing, malformed, replayed or stale proof; proof for another method, URL, token or key | 401, reason dpop_proof_invalid; the outcome label names the failed check (missing_proof, malformed, replay, iat, htm, htu, ath, jkt, signature) |
Unbound token with the DPoP scheme |
401, reason dpop_unbound |
Unbound token as Bearer |
Accepted, unless dpop.require (401, reason dpop_required) |
| Bound token at the egress proxy | 407, reason dpop_proxy: use the unbound token there |
Refusals carry WWW-Authenticate: DPoP algs="EdDSA ES256", error="...". Proofs may be at most maxAgeSeconds old (default 60, with 5 seconds of clock skew).
Troubleshooting#
| Symptom | Cause | Fix |
|---|---|---|
401 dpop_proof_invalid, outcome htu |
The proof names another URL than the gateway sees (proxy in front) | Set dpop.publicURL to the URL agents use |
Outcome iat |
Clock skew, or the proof was built too early | Sync clocks; build each proof right before the request |
Outcome replay |
A proof was reused (retries) | Build a new proof for every attempt |
Outcome ath |
The proof was built over the unbound token | Use the bound token from the bind response |
409 from /v1/token/bind |
The token is already bound to another key | Mint a new token; investigate a possible copy |
401 dpop_required |
Gateway requires DPoP and the agent sent Bearer |
Enable DPoP in the client |
| Bind fails from a cluster sandbox | Sandboxes cannot reach the broker | Mint bound tokens with dpopJkt |
Limits#
- Replay detection is per replica: a proof replayed to another gateway replica within
maxAgeSecondsis not detected. Server nonces are not issued. - Tokens from external identity providers (
identity.trustedIssuers) are not accepted with theDPoPscheme. - Upstreams reached by token exchange receive a token bound to the agent's key. An upstream that enforces
cnfrefuses it.