DPoP Sender-Constrained Tokens
DPoP (Demonstrating Proof of Possession, RFC 9449) binds an access token to a key pair that the client holds. Descope supports DPoP for Inbound Apps and Agentic Clients at the token endpoint and the UserInfo endpoint.
Why Use DPoP
A normal access token is a bearer token: whoever holds it can use it. If a token leaks from a log, a browser, a compromised agent, or a misconfigured proxy, an attacker can call your APIs with it until it expires.
DPoP closes that gap. The client creates a key pair and keeps the private key. Descope writes the public key's thumbprint into the access token, and the client signs a short DPoP proof for every request it makes with the token. A Resource that checks the proof rejects the token when someone sends it without a matching signature. A stolen token is useless without the private key, which never leaves the client.
DPoP matters most for public clients, such as single-page apps, mobile apps, CLIs, and agents, which can't hold a client secret. Some security profiles, such as FAPI 2.0, also require a sender-constrained token.
How It Works
- The client generates a key pair. Descope accepts proofs signed with
RS256,RS384,RS512,PS256,PS384,PS512,ES256,ES384,ES512, orEdDSA. - The client calls the token endpoint with a
DPoPheader that holds a proof JWT signed with its private key. - The first request has no nonce, so Descope rejects it with
use_dpop_nonceand returns a nonce in theDPoP-Nonceresponse header. - The client signs a new proof that includes the nonce and sends the request again.
- Descope validates the proof and issues an access token with a
cnf.jktclaim, the thumbprint of the client's public key. The token response hastoken_typeset toDPoP. - The client calls your Resource with
Authorization: DPoP <access_token>and a new proof for that request. - Your Resource validates the token, then checks that the proof was signed by the key in
cnf.jktand covers this token, method, and URL.
Requesting a DPoP-Bound Token
DPoP works with every grant at the token endpoint. The client adds the DPoP header to the token request and changes nothing else:
curl -X POST "__BaseURL__/oauth2/v1/apps/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "DPoP: <DPOP_PROOF_JWT>" \
-d "grant_type=authorization_code" \
-d "client_id=<CLIENT_ID>" \
-d "code=<AUTHORIZATION_CODE>" \
-d "code_verifier=<CODE_VERIFIER>" \
-d "redirect_uri=https://yourapp.com/callback"The DPoP header holds a proof, described in DPoP Proof Format.
Each successful token response includes a fresh nonce in DPoP-Nonce. The client should use that value in its next proof. A nonce expires after five minutes. When the nonce is missing, expired, or already used, Descope returns use_dpop_nonce with a new nonce, and the client can retry.
Binding the Authorization Request
For the authorization code grant, the client can bind the code to its key before the user signs in. The client sends the key's thumbprint as dpop_jkt on the authorize request or the pushed authorization request. Descope then issues tokens for that code only to a token request whose proof uses the same key. The device code and CIBA requests accept dpop_jkt the same way.
Refresh Tokens
A refresh token that Descope issued to a public client is bound to the same key. When the client refreshes, it must send a proof signed with that key, or Descope rejects the refresh. A confidential client already authenticates on every refresh, so Descope lets it switch to a new DPoP key when it refreshes.
DPoP Proof Format
A DPoP proof is a short-lived JWT that the client signs for a single HTTP request. The client creates a new proof for every request, including every retry, and sends it in the DPoP header. Unlike an access token, Descope doesn't issue the proof. The client builds and signs it with its own private key.
The proof's JOSE header identifies it as a DPoP proof and carries the public half of the client's key, so the receiver can verify the signature without looking up a key:
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs",
"y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA"
}
}typ: Alwaysdpop+jwt.alg: An asymmetric algorithm from the list in How It Works. Descope rejects symmetric algorithms such asHS256.jwk: The client's public key. It must not contain private key fields.
The proof's payload ties it to one request. A proof for a token request looks like this:
{
"jti": "e1j3V_bKic8-LAEB",
"htm": "POST",
"htu": "https://api.descope.com/oauth2/v1/apps/token",
"iat": 1793212800,
"nonce": "eyJ7S_zG.eyJH0-Z.HX4w-7v"
}jti: A unique ID for this proof. Descope rejects a proof whosejtiit has already seen.htm: The HTTP method of the request, such asPOST.htu: The URL of the request. Descope compares it without the query string or fragment.iat: The time the client created the proof. Descope accepts a proof created within the last 60 seconds, and no more than 10 seconds in the future.nonce: The latest value from theDPoP-Nonceresponse header. Descope requires it on every token request that carries a proof.
A proof sent to a Resource along with a DPoP-bound access token also needs an ath claim. Its value is the base64url-encoded SHA-256 hash of the access token, which ties the proof to that specific token:
{
"jti": "Bq3p_6rTz1Vk9xQe",
"htm": "GET",
"htu": "https://api.example.com/orders",
"iat": 1793212860,
"ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo"
}The access token that Descope issues records the key in its cnf claim. The jkt value is the RFC 7638 SHA-256 thumbprint of the client's public key, base64url-encoded. The dpop_jkt parameter on an authorization request uses the same thumbprint:
{
"cnf": {
"jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"
}
}Many OAuth client libraries that support DPoP build these proofs for you. If yours doesn't, any JOSE library can sign them.
Requiring DPoP
DPoP is optional by default. A client that sends a proof gets a DPoP-bound token, and a client that doesn't gets a regular bearer token. You can require DPoP per Inbound App, so one app can require it while the others keep accepting bearer token requests. To require it, turn on Require DPoP under Client Authentication on the app. Descope then rejects any token request from that app that doesn't carry a valid proof.

The toggle appears only for apps with an explicit Confidential or Public client type. You can also set it with the Management API, where the field is forceDpop, or with the force_dpop attribute of the descope_inbound_app resource in the Terraform provider.
Validating DPoP in Your Resource
A DPoP-bound token protects you only if your Resource checks the proof. If your Resource validates the JWT and ignores the DPoP header, it accepts a stolen token as if it were a bearer token.
For each request with a DPoP-bound token, your Resource should check that:
- The
Authorizationheader uses theDPoPscheme, notBearer. - The
DPoPheader holds a proof signed by the key whose thumbprint is in the token'scnf.jktclaim. - The proof's
htmandhtumatch this request, and itsiatis recent. - The proof's
athclaim is the base64url-encoded SHA-256 hash of the access token. - The proof's
jtihasn't been used before.
With a Descope SDK
The Node.js and Go SDKs check DPoP proofs during session validation. They handle it differently.
The Node.js SDK checks the proof when you pass the request's DPoP details to validateSession or validateAndRefreshSession. Without the dpop option, the SDK accepts a DPoP-bound token and logs a warning, so pass it on every route that accepts these tokens:
const [authorizationScheme, accessToken] = (req.headers.authorization ?? '').split(' ');
const authInfo = await descopeClient.validateSession(accessToken, {
dpop: {
proof: req.headers['dpop'] as string | undefined,
method: req.method,
url: `${req.protocol}://${req.get('host')}${req.path}`,
authorizationScheme,
},
});The url must match the proof's htu. Behind a proxy, Node.js may see http while the client used https, so enable Express trust proxy or build the URL yourself.
Note
Both SDKs keep used jti values in memory, so they can't detect a replay that reaches a different server instance.
Without a Descope SDK
The Python, Java, Ruby, .NET, and PHP SDKs don't check DPoP proofs. In those languages, validate the access token with the SDK as usual. Then check the proof yourself with a JOSE library, following the list at the start of this section and the proof format. If you can't add these checks yet, don't turn on DPoP for clients that call that Resource, because the Resource would treat a stolen DPoP-bound token as a bearer token.
At Descope Endpoints
Descope's own UserInfo endpoint applies the same checks. To call it with a DPoP-bound token, send Authorization: DPoP <access_token> and a proof with an ath claim.
Related
Pushed Authorization Requests
Send OAuth authorization parameters to Descope over a back-channel call with PAR (RFC 9126), so the browser redirect carries only a short request_uri.
Using Inbound Apps
Learn how to integrate inbound apps with Descope to streamline OAuth authentication, manage user consent, and securely connect third-party applications.