Sender-constrained tokens (DPoP)
By default, Zynth Auth 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.
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.
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.
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.