Skip to main content

DPoP: Sender-Constrained Tokens

Demonstrating Proof-of-Possession at the Application Layer (RFC 9449) (DPoP) binds an access token to a public key held by the client. ThunderID embeds a thumbprint of the client's key in the token's cnf.jkt claim. The resource server then requires every request to carry a fresh DPoP proof JWT signed with the matching private key. A token stolen in transit is useless without the private key.

DPoP is an alternative to mutual TLS bound tokens (RFC 8705, not supported in ThunderID) that works at the application layer without TLS client certificates.

How It Works

The proof JWT is a one-time, short-lived JWT signed by the client. Each call to ThunderID or to a protected resource carries its own fresh proof.

How ThunderID Implements It
AspectBehavior
DetectionA DPoP header on /oauth2/token switches the response token_type from Bearer to DPoP
Token bindingThe proof's public-key thumbprint (jkt) is embedded in the issued access token as cnf.jkt
Refresh token bindingRefresh tokens issued under DPoP are also sender-constrained; refreshing requires a DPoP proof
Supported signing algorithmsRS256, RS512, PS256, ES256, ES384, ES512, EdDSA (configurable via oauth.dpop.allowed_algs). Symmetric algorithms are rejected.
Header typeThe proof's JWT header must include typ: dpop+jwt
Replay protectionJTI-based replay store; each jti accepted at most once within the configured window
Where DPoP applies/oauth2/token, /oauth2/par, and downstream resource servers that validate the proof
Server-issued noncesNot implemented: ThunderID does not issue DPoP-Nonce challenges today

Required Proof Claims

The client signs a JWT with this structure for every protected request:

ClaimWhereValue
typ (header)JOSE headerdpop+jwt
alg (header)JOSE headerAsymmetric signing algorithm
jwk (header)JOSE headerThe client's public key in JWK form
jtiPayloadUnique value per proof
htmPayloadHTTP method of the request, e.g. POST
htuPayloadHTTP URI of the request, e.g. https://api.example.com/payments
iatPayloadIssued-at, within the server's freshness window
athPayload(Resource server calls only) Base64url SHA-256 of the access token

What a Resource Server Validates

CheckWhat
SignatureVerify the proof's signature against jwk in its header
ThumbprintCompute the thumbprint (RFC 7638) of jwk and compare it to the access token's cnf.jkt
htm / htuMatch the actual request method and URI
iatWithin the freshness window
jtiNot seen before
athSHA-256 of the access token, when present

Try It in ThunderID

DPoP activates implicitly: send a DPoP header on the token request and the issued token will be DPoP-bound. No per-application opt-in is required: any client may use DPoP if it wants to.

Request a DPoP-Bound Token

# DPOP_PROOF is a JWT signed by the client, with htm=POST and htu pointing at the token endpoint
curl -X POST https://thunderid.example.com/oauth2/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-H "DPoP: $DPOP_PROOF" \
-d "grant_type=authorization_code" \
-d "code=$CODE" \
-d "redirect_uri=https://app.example.com/callback" \
-d "code_verifier=$CODE_VERIFIER"

Response (DPoP token type, with cnf.jkt claim in the access token):

{
"access_token": "eyJhbGciOi...",
"token_type": "DPoP",
"expires_in": 3600,
"refresh_token": "eyJhbGciOi...",
"scope": "openid profile"
}

Call a Protected Resource

# A fresh proof per request, with htm=GET and htu pointing at the resource
curl -X GET https://api.example.com/payments \
-H "Authorization: DPoP $ACCESS_TOKEN" \
-H "DPoP: $DPOP_PROOF_FOR_THIS_CALL"

Refresh a DPoP-Bound Token

curl -X POST https://thunderid.example.com/oauth2/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-H "DPoP: $DPOP_PROOF" \
-d "grant_type=refresh_token" \
-d "refresh_token=$REFRESH_TOKEN"

Explore with AI

ThunderID LogoThunderID Logo

Product

DocsAPIsSDKs
© Copyright Linux Foundation Europe.For web site terms of use, trademark policy and other project policies please see https://linuxfoundation.eu/en/policies.