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
- Your client generates a key pair (e.g. EC P-256) and keeps the private key.
- 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. - On every subsequent request, the client sends
Authorization: DPoP <access_token>plus a fresh proof in theDPoPheader. 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:
| Field | Value |
|---|---|
typ | dpop+jwt |
alg | an asymmetric algorithm — ES256 (recommended), ES384, ES512, RS256, PS256 |
jwk | your public key as a JWK (never the private key) |
Claims:
| Claim | Value |
|---|---|
htm | the HTTP method, e.g. GET, POST |
htu | the request URL without query or fragment — use your public URL |
iat | issued-at (seconds); must be within ~60s of the server clock |
jti | a unique random value (single-use — a replayed proof is rejected) |
ath | on 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):
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):
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_typeisDPoPfor a bound token andbearerfor an unbound one. Switch yourAuthorizationscheme on it: a bound token is sent asAuthorization: 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
400and 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 withcreateDpopFetch. - 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
Authorizationheader 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 withopenssl dgst -sha256 -signand convert the DER signature to the fixed 64-byter || sform a JWS carries. Send the proof for the mint, then spend the token it returns asAuthorization: DPoP <token>with a proof for each call:htmandhtuof that request, andathof the token. A token minted withoutcnf(a deployment that allows bearer) is sent asBearerand needs no proof, so decide the scheme from the mint'stoken_type. A gateway that forwards yourAuthorizationheader 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 freshDPoPproof whosehtmandhtuname that request (the method and the public URL you called on the content or assurance API, not the platform's address) and whoseathis 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
401on 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
401from these services. The body isinvalid or expired credentialsfor 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'shtufirst (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 ajti. A503means 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.
iatmust 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
htufrom 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.