MAQPNADocs

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/token at 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/bind with Authorization: Bearer <session token> and a proof over it. The broker returns the same token (same subject, scopes, expiry and jti, so revoking the jti revokes both) plus cnf.jkt. A token binds to one key; another key gets 409.

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#

  1. The call succeeds. Its audit record carries dpopJkt in ext:

    eval "$(maqpna dev env)"     # loads the local admin token
    maqpna audit tail --gateway http://127.0.0.1:8080 --tail 1 -o json
    
  2. Send the bound token as Bearer, or reuse a proof: the gateway answers 401.

  3. Scrape /metrics and read maqpna_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 maxAgeSeconds is not detected. Server nonces are not issued.
  • Tokens from external identity providers (identity.trustedIssuers) are not accepted with the DPoP scheme.
  • Upstreams reached by token exchange receive a token bound to the agent's key. An upstream that enforces cnf refuses it.

Next steps#