Private betaRequest early access →

Sign in

Sender-constrained tokens (DPoP)

By default, Zynth Auth (Perihelium's authentication layer) issues bearer tokens: whoever holds one can use it. DPoP (Demonstration of Proof-of-Possession, RFC 9449) binds the tokens to a key your client holds, so a token stolen in transit or from a log is useless without the private key. This is recommended for API, SDK, and machine-to-machine clients.

For people signing in, DPoP is opt-in per token and fully backward-compatible: if you don't send a proof, you get ordinary bearer tokens and nothing changes. For AI agents it is the default posture: a deployment can require every agent token to be bound, and outside development that is what it does — see AI agents.

How it works

  1. Your client generates a key pair (e.g. EC P-256) and keeps the private key.
  2. On any sign-in request, it sends a DPoP proof — a short JWT signed by that key, with the public key embedded in its header. Zynth Auth binds the issued access + refresh tokens to the key's thumbprint and responds with token_type: DPoP.
  3. On every subsequent request, the client sends Authorization: DPoP <access_token> plus a fresh proof in the DPoP header. Zynth Auth verifies the proof matches the token's bound key.

A DPoP-bound token presented without a proof, or as a plain Bearer token, or with a different key, is rejected (401). That downgrade protection is the whole point.

The proof JWT

Header:

FieldValue
typdpop+jwt
algan asymmetric algorithm — ES256 (recommended), ES384, ES512, RS256, PS256
jwkyour public key as a JWK (never the private key)

Claims:

ClaimValue
htmthe HTTP method, e.g. GET, POST
htuthe request URL without query or fragment — use your public URL
iatissued-at (seconds); must be within ~60s of the server clock
jtia unique random value (single-use — a replayed proof is rejected)
athon resource requests, the base64url SHA-256 of the access token you're presenting. Omit at the token endpoint (sign-in / refresh).

Example flow

1. Sign in with a proof (no ath — there's no access token yet):

Text
POST /api/v1/auth/login
DPoP: <proof: htm=POST, htu=https://auth.zynthmedia.com/api/v1/auth/login>
{ "email": "...", "password": "...", "tenant_slug": "..." }

→ 200  { "access_token": "...", "refresh_token": "...", "token_type": "DPoP" }

2. Call a protected endpoint (ath binds the proof to this token):

Text
GET /api/v1/me
Authorization: DPoP <access_token>
DPoP: <proof: htm=GET, htu=https://auth.zynthmedia.com/api/v1/me, ath=SHA256(access_token)>

→ 200

3. Refresh — send a proof signed by the same key (token endpoint → no ath). The rotated tokens re-bind to your key. A stolen bound refresh token can't be rotated without your key.

AI agents

Both ways an agent gets a token — POST /api/v1/agents/token (with its credential) and the federated exchange (with a workload attestation) — take the same DPoP header and bind the token the same way.

  • The response says which you got. token_type is DPoP for a bound token and bearer for an unbound one. Switch your Authorization scheme on it: a bound token is sent as Authorization: DPoP <token> with a fresh proof on every call.
  • Required by default outside development. A request to either mint without a proof is refused with 400 and a message naming the fix, and no token is issued. A prober, a script or a forgotten integration therefore cannot end up holding an unbound agent token. Every agent client must sign proofs: the TypeScript SDK does it for you with createDpopFetch.
  • When bearer is allowed. An operator who needs bearer agent tokens — a migration, a development stack — states it explicitly with AGENT_DPOP_REQUIRED=false. Where it is allowed, a request with no proof still gets a bearer token (token_type: bearer), and a request with a proof still gets a bound one.
  • Static-header clients. A client that can only attach a fixed Authorization header to every request (many MCP clients configured with --header) cannot present a bound token, because each call needs its own proof. Connect those through an SDK-based client, or ask your operator which token policy your deployment uses.
  • Scripts and scheduled jobs. A shell script or CI job signs the same proofs: generate one P-256 key per agent, keep it in a file only that job can read (mode 0600, never in a log or a command line), sign the proof's header and claims with openssl dgst -sha256 -sign and convert the DER signature to the fixed 64-byte r || s form a JWS carries. Send the proof for the mint, then spend the token it returns as Authorization: DPoP <token> with a proof for each call: htm and htu of that request, and ath of the token. A token minted without cnf (a deployment that allows bearer) is sent as Bearer and needs no proof, so decide the scheme from the mint's token_type. A gateway that forwards your Authorization header to another service for validation cannot carry a bound token, because the proof is only valid for the request it was made for.
  • Clock skew now matters to agents. An agent host whose clock is off by more than the proof window fails to mint rather than minting an unbound token. Keep agent hosts on NTP.

Calling the content and assurance APIs with a bound token

The content (CMS) API and the assurance API are separate services behind the same public address, and neither authenticates anyone on its own: each asks the Zynth Auth platform who the caller is. A bound agent token works against them without anything extra to send. Agents reach the content tools through the platform's MCP gateway (below); the assurance API is called directly:

  • Calling the assurance API directly: send what you already send. Authorization: DPoP <token> and a fresh DPoP proof whose htm and htu name that request (the method and the public URL you called on the content or assurance API, not the platform's address) and whose ath is the token's hash. Do not send anything else; the service forwards your proof to the platform on your behalf and the platform checks it once, against the URL you actually called.
  • Content tools through the MCP gateway need nothing extra. An agent that drafts content calls the platform's MCP gateway (/api/v1/mcp/call/zynth-content), not the content service directly. Present the bound token and a proof for that gateway request, as for any platform call; the gateway verified the proof itself and hands the content service a short-lived confirmation alongside your token. There is no second proof to produce and no header to add.
  • One proof per request, as everywhere. A proof can be shown once. The service verifies it with its first question to the platform and covers the rest of that same request (an approval check, a follow-up read) with a short-lived confirmation the platform gives it, bound to your token and to that service. You never see it and cannot reuse it.
  • A request that runs for more than about a minute can fail late with 401 on a follow-up step, rather than being given more time. Keep calls short, or split the work across requests.
  • A bearer-scheme request with a bound token is refused, as on the platform API: the scheme word does not decide anything, the token's own binding does.
  • Reading a 401 from these services. The body is invalid or expired credentials for every platform refusal: a missing or stale proof, a proof for another URL or key, a replayed proof, a token that was revoked. Check the proof's htu first (it must be the URL you called, as the public internet sees it, including the scheme), then the clock, then that you did not reuse a jti. A 503 means the platform could not be reached; retry.
  • The content API is for agents only through the gateway. The public address serves the content API to the console behind a path prefix that the edge removes before the service sees the request, so a proof that names the URL you called cannot match the one the service reconstructs. Use the gateway for agent work (it needs no such proof); a bound token called directly against that prefix is refused.
  • Third-party resource servers are not covered by this. A service you run yourself that wants to accept Zynth Auth tokens validates them through token introspection and checks the proof itself; the confirmation described above is between the platform's own services only.

Edge cases to handle

  • Clock skew. iat must be within the server's window (default ±60s). Keep your client clock synced; if you can't, request a bearer token instead.
  • Key rotation. The binding is fixed when the token is issued. To move to a new key, sign in again (or refresh) with a proof under the new key — the new tokens bind to it.
  • Behind a proxy/CDN. Compute htu from the public URL your client called, not any internal address — that's the URL Zynth Auth compares against.
  • Replay. Never reuse a jti; generate a fresh proof per request.

When to use it

  • ✅ API keys, SDK clients, service-to-service — highest value; longer-lived credentials.
  • ➖ Browsers — the browser already keeps its long-lived refresh token in an httpOnly cookie out of JavaScript's reach, and access tokens are short-lived, so DPoP adds little there. Zynth Auth's own web app uses bearer + cookie; a first-party SDK is the natural DPoP client.