DPoP Integration
DPoP integration
DPoP protects access and refresh tokens if they are copied. The SPA or BFF keeps a
private signing key and sends a new signed proof with every protected request. Truster
validates proofs only for requests to its own endpoints, including /par, /token,
/revoke, and /userinfo. Each application API must independently validate the access
token and the DPoP proof for requests it receives.
DPoP still requires TLS, PKCE, and normal token validation. It does not encrypt tokens or stop malicious code already running inside the SPA or BFF from using the key.
Configure a client
"dpop": {
"mode": "required",
"signing_algorithm": "ES256"
},
"require_par": truemode is disabled by default. When it is required, the signing algorithm defaults
to ES256. ES256 is normally the best choice because it provides strong security with
smaller, faster proofs. Use ES512 only when your security policy requires its stronger
P-521 profile and accepts larger signatures and more CPU work.
Give Bearer and DPoP clients different client IDs. If you need to change a client’s DPoP mode or signing algorithm, create a new client ID instead; existing logins may otherwise stop working. Losing the private key also requires a new login.
Browser clients should set require_par: true. PAR saves the authorization request in
Truster before the browser redirect, so the redirect cannot change its PKCE or DPoP
values.
The flow at a glance
- SPA or BFF: Create a signing key and keep it for the login and all later token refreshes.
- SPA or BFF: Send the login request to
/parwith the key’s thumbprint, a DPoP proof, or both. - Browser: Open
/authorizewith the returned one-userequest_uriand complete login. - SPA or BFF: Exchange the authorization code at
/tokenwith a fresh proof from the same key. - SPA or BFF: Present the access token with a new proof on every API request.
- SPA or BFF: Use that key again when refreshing tokens or revoking the refresh grant.
Truster receives the public key and its thumbprint, never the private key.
Create the key and thumbprint
For a direct SPA, create a non-extractable ECDSA CryptoKey with Web Crypto. Use P-256
for ES256 or P-521 for ES512. A BFF creates the same kind of key on the server. Give each
saved account or login its own key instead of sharing one key across users.
Export only the public key as a JWK. To calculate dpop_jkt, serialize its required
members in this exact order, hash the UTF-8 JSON with SHA-256, then encode the hash with
URL-safe base64 and omit the trailing = padding:
{"crv":"P-256","kty":"EC","x":"...","y":"..."}Use P-521 in crv for ES512; the thumbprint still uses SHA-256. Keep the private key,
thumbprint, PKCE verifier, state, and expected ID-token nonce together until the
login callback is complete. Keep the key afterward for refresh and revocation.
Create a proof
A DPoP proof is a short-lived signed JWT describing the HTTP request you are about to
send. Put the compact JWT in exactly one DPoP header. Its JWT header contains:
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
}Its payload contains:
jti: a new unpredictable ID; generate another one for every retry;htm: the request’s uppercase HTTP method, such asPOST;htu: the exact public URL being called, without its query or fragment;iat: the current Unix timestamp in seconds; andath: only when calling an API, the SHA-256 hash of the exact access-token text, encoded with URL-safe base64 without trailing=padding.
Truster accepts iat from ten seconds in the past through five seconds in the future.
Proofs are limited to 8 KiB. Create a new proof for every request and retry. For htu,
use the public URL advertised to clients, not an internal address behind a proxy.
Start authorization with PAR
POST the normal Authorization Code + PKCE form fields and client_id to /par. Also
send dpop_jkt, a proof whose htu is the public /par URL, or both. If you send both,
they must identify the same key.
Truster returns a request_uri that expires after 60 seconds and works once. Open
/authorize in the browser with only that value and client_id. With require_par
enabled, Truster rejects login requests that skip /par.
Exchange, refresh, and revoke
When exchanging the code or using a refresh token, send a new proof whose htu is the
public /token URL. Sign it with the same key used during login. A successful response
contains token_type: DPoP, and the access token contains the key’s thumbprint in its
cnf.jkt field.
To revoke the refresh grant—the server-side session behind the refresh token—POST the
token and client_id to /revoke with a new proof for the public /revoke URL. This
proof does not need ath. Even if this network request fails, mark the local session
logged out and delete its local tokens and key.
Call an API
Authorization: DPoP <access-token>
DPoP: <fresh-proof-with-ath>Truster does not validate requests sent to an application API. The API must independently validate the access token and proof before handling the request:
- Validate the token’s signature, issuer, audience, expiry, and authorization claims.
- Require
Authorization: DPoP <access-token>; never accept a token containingcnf.jktasAuthorization: Bearer <access-token>. - Validate the proof signature, supported algorithm, matching key curve, and embedded
public key. Check
htm, exact publichtu,iat, andathagainst the request and token. - Require the proof key’s thumbprint to equal the token’s
cnf.jkt. - Require a short proof lifetime. A bounded in-memory replay cache can additionally reject reuse seen by the same API replica during that window.
Reject private or symmetric embedded keys and JWT headers that refer to keys on another server. Truster does not use the optional DPoP nonce feature.
Replay protection and failures
RFC 9449 requires a short proof lifetime; strict global single-use tracking is optional
and can be impractical across replicas. Truster retains replay hashes in a bounded
in-memory cache for its 15-second acceptance window. A replay reaching the same process
is always rejected without a database write or database availability dependency. The
cache stores only a hash of the thumbprint, jti, method, and URL—not proofs, tokens,
public keys, or raw jti values.
Each replica has an independent cache, so cross-replica replay detection is best-effort
rather than guaranteed. This is intentional and permitted by RFC 9449. Kubernetes
client-IP affinity does not reliably keep one DPoP key on one replica, so Truster does
not require or enable it. A gateway that supports consistent hashing by the complete
DPoP header can route an exact replay to the same backend, but that is an optional,
provider-specific mitigation. Losing a replay cache does not require an outage or a
15-second HTTP 503 recovery period.
Truster limits PAR, token, and revocation separately to 100 requests per second per process, with a burst of 200, before request parsing or database access. Keep per-user or per-IP limits at your reverse proxy or API gateway. Monitor replay attempts, rate-limit rejections, request latency, and server clock accuracy.
/par returns invalid_request when a required client sends neither dpop_jkt nor a
proof. Token, revocation, and API calls report invalid_dpop_proof when a required proof
is missing, too old, reused, or created for another method or URL. API errors use the
WWW-Authenticate response header; token and revocation errors use JSON.
For complete SPA and backend-for-frontend designs, see the app integration guide.