Skip to content
Last updated

DPoP — sender-constrained tokens

DPoP (Demonstrating Proof-of-Possession, RFC 9449) binds an access token to a private key that only your application holds. A token obtained with DPoP is useless to anyone who does not also hold your key, so a leaked token cannot be replayed. DPoP is optional. If you do not send a DPoP proof, you get an ordinary Bearer token, which is fine for most integrations.

Use DPoP when your security policy requires proof-of-possession — for example, when tokens travel across less-trusted networks.

How it works

  1. Your application holds an asymmetric key pair.
  2. With each request, you send a short-lived DPoP proof: a JWT signed with your private key. Its header carries the matching public key (JWK).
  3. Hyperproof ties the access token to the thumbprint of that key (cnf.jkt). Each call after establishing the key pair must present a fresh proof signed by the same key.

Supported proof-signing algorithms: ES256, ES384, ES512, RS256, PS256 (ES256 is recommended). A proof is valid for 60 seconds, and each proof's jti may be used only once.

The proof JWT

Header

FieldValue
typdpop+jwt
algyour signing algorithm, e.g. ES256
jwkyour public key (public members only)

Claims

ClaimMeaning
htmHTTP method of the request, e.g. POST
htuHTTP URL of the request (without query or fragment)
iatissued-at time (Unix seconds)
jtiunique random id (replay protection)
athbase64url SHA-256 hash of the access token — required on API calls; omit it on the token request

Step 1 — Get a DPoP-bound token

Send a proof for the token endpoint (htm=POST, htu=…/oauth/token, no ath) in the DPoP header of the token request. The response token_type comes back as DPoP.

# Python — using PyJWT (cryptography backend)
import jwt, time, uuid, hashlib, base64
from cryptography.hazmat.primitives.asymmetric import ec

private_key = ec.generate_private_key(ec.SECP256R1())
public_numbers = private_key.public_key().public_numbers()

def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

jwk = {"kty": "EC", "crv": "P-256",
       "x": b64u(public_numbers.x.to_bytes(32, "big")),
       "y": b64u(public_numbers.y.to_bytes(32, "big"))}

def dpop_proof(htm, htu, access_token=None):
    claims = {"htm": htm, "htu": htu, "iat": int(time.time()), "jti": uuid.uuid4().hex}
    if access_token:
        claims["ath"] = b64u(hashlib.sha256(access_token.encode()).digest())
    return jwt.encode(claims, private_key, algorithm="ES256",
                      headers={"typ": "dpop+jwt", "jwk": jwk})

token_url = "https://api.expent.ai/tprm-api/oauth/token"
# POST token_url with header DPoP: dpop_proof("POST", token_url)
# and form grant_type=client_credentials  ->  token_type "DPoP"

Step 2 — Call the API with the DPoP token

On every request, send both the token (with the DPoP auth scheme, not Bearer) and a fresh proof that includes ath:

curl -X POST https://api.expent.ai/tprm-api/vendors/search \
  -H "Authorization: DPoP ACCESS_TOKEN" \
  -H "DPoP: <proof with htm=POST, htu=…/vendors/search, ath=SHA256(ACCESS_TOKEN)>" \
  -H "Content-Type: application/json" -d '{}'

A missing or invalid proof returns 401 with client_code auth.invalid_dpop.

Tip: In the interactive reference, every operation shows ready-to-run request samples in curl, Python, JavaScript, and more. The samples are generated automatically from the spec.