Private betaZynth Auth is currently in private beta testing.New organizations are created by invitation only, and no plan can be purchased yet.Request early access

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

  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):

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. 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.