Demonstrating Proof-of-Possession (DPoP)
LoginRadius implements RFC 9449 Demonstrating Proof-of-Possession (DPoP) for OAuth 2.0 and OpenID Connect applications. A DPoP-bound access token is sender-constrained: the client proves possession of the private key that matches the token's cnf.jkt claim on every token and resource request.
A leaked Bearer token can be replayed by anyone who holds it. A DPoP-bound token cannot be used without a fresh, request-bound proof signed by that key.
Why it matters
Bearer access tokens are bearer credentials. If a token appears in a log, a Referer header, or a compromised client, an attacker can call your APIs as that user. DPoP binds the token to a public key so the authorization server and resource server accept the token only with a matching proof.
DPoP complements other OAuth protections. It does not replace them.
| Control | What it protects | LoginRadius docs |
|---|---|---|
| PKCE | Authorization code interception on public clients | PKCE Flow |
| Resource Indicators | Token audience (aud) so one API cannot accept a token issued for another | Resource Indicators |
| DPoP | Token sender: the caller must hold the bound private key | This page |
Use PKCE and DPoP together on public clients (SPAs, mobile, Auth Studio). Use Resource Indicators when the token must be valid for a specific API only.
How it works
The client generates an asymmetric key pair, sends the public-key thumbprint on /authorize, proves possession on /token, and proves possession again on every resource call.
Key concepts
These terms appear on the wire and in the Admin Console. Use them when you read the request and response examples below.
| Concept | Description |
|---|---|
dpop_jkt | JWK thumbprint (RFC 7638) of the client's public key, sent on /authorize so the authorization code is bound to that key. |
| DPoP proof | Compact JWT (typ: dpop+jwt) sent in the DPoP HTTP header. Each request gets a new proof. |
jkt | SHA-256 thumbprint of the proof's public JWK. |
cnf.jkt | Confirmation claim on the access token. The resource server matches this to the proof's jkt. |
token_type | DPoP when the access token is bound. Use the DPoP HTTP scheme, not Bearer. |
ath | Hash of the access token, required on resource-request proofs (RFC 9449 §4.2). |
DPoP-Nonce | Server-issued nonce header. Required in the next proof when Require DPoP nonce is on. |
use_dpop_nonce | Token-endpoint error telling the client to retry once with the nonce from DPoP-Nonce. |
Enable DPoP in the Admin Console
DPoP is configured per OAuth 2.0 or OIDC application on the Tokens tab. The tenant DPoP feature must be on before the card appears.
DPoP requires the tenant feature enable_dpop. Contact LoginRadius Support if the Demonstrating Proof-of-Possession (DPoP) card is not visible on the app Tokens tab.
- In the Admin Console, open Applications and select your OAuth 2.0 or OIDC app.
- Open the Tokens tab and find Demonstrating Proof-of-Possession (DPoP).
- Turn on Enable DPoP. The app can issue DPoP-bound access tokens (
token_typeDPoPwithcnf.jkt). Discovery advertisesdpop_signing_alg_values_supported. - Optionally turn on Require DPoP. Token requests without a valid DPoP proof are rejected. Dynamic clients (DCR and CIMD) follow this parent setting at request time.
- Optionally turn on Require DPoP nonce. LoginRadius challenges clients with
use_dpop_nonceand requires proofs to include the server-issuedDPoP-Nonce.
| Setting | JSON field | Effect |
|---|---|---|
| Enable DPoP | DPoP.IsEnabled | App may issue DPoP-bound tokens. Clients that send a proof receive token_type DPoP. |
| Require DPoP | DPoP.IsRequired | Token requests without a valid proof are rejected. |
| Require DPoP nonce | DPoP.NonceRequired | Proofs must include a server nonce. Missing or stale nonce returns use_dpop_nonce. |
Turn on Require DPoP only for applications whose clients send DPoP proofs. A token request with no DPoP header is rejected. Create a dedicated app for DPoP clients if other clients still send Bearer-only token requests.
Discovery
When DPoP is enabled for the app, LoginRadius advertises the supported proof algorithms on both metadata documents.
https://{SiteURL}/service/oidc/{OIDCAppName}/.well-known/openid-configuration
https://{SiteURL}/.well-known/oauth-authorization-server/service/oidc/{OIDCAppName}
Replace oidc with oauth and {OIDCAppName} with {OAuthAppName} for OAuth 2.0 apps. Copy the exact URLs from the app Endpoints tab.
{
"dpop_signing_alg_values_supported": ["ES256", "RS256"]
}
LoginRadius accepts DPoP proofs signed with ES256 (P-256) or RS256. Hosted Auth Studio and typical public clients use ES256.
Step 1: Authorization request (dpop_jkt)
Send dpop_jkt on the authorization request so the issued code is bound to your key. Do not send a DPoP proof on /authorize. The proof is used on /token and on resource requests.
OIDC
OAuth 2.0
GET https://{SiteURL}/service/oidc/{OIDCAppName}/authorize
GET https://{SiteURL}/service/oauth/{OAuthAppName}/authorize
Example request
GET /service/oidc/MyApp/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https%3A%2F%2Fyour-app.com%2Fcallback
&scope=openid%20profile
&state=xyz123
&code_challenge=BASE64URL_SHA256_VERIFIER
&code_challenge_method=S256
&dpop_jkt=YOUR_JWK_THUMBPRINT HTTP/1.1
Host: your-app.hub.loginradius.com
| Parameter | Required | Description |
|---|---|---|
client_id | Required | Application Client ID. |
response_type | Required | code for Authorization Code. |
redirect_uri | Required | Whitelisted callback URL. |
scope | Required for OIDC | Include openid for OIDC. |
code_challenge / code_challenge_method | Required when PKCE is enforced | S256. See PKCE Flow. |
dpop_jkt | Required when DPoP is required | RFC 7638 SHA-256 thumbprint of the client's public JWK (EC crv/x/y or RSA n/e, lexicographic JSON). |
state | Recommended | CSRF correlation value. |
After authentication, LoginRadius redirects to redirect_uri with code and state. Exchange that code on /token with a DPoP proof whose jkt matches dpop_jkt.
Step 2: Token request (DPoP header)
POST the token endpoint with a DPoP proof JWT. The proof htu is the token URL with no query or fragment. The proof htm is POST.
OIDC
OAuth 2.0
POST https://{SiteURL}/api/oidc/{OIDCAppName}/token
POST https://{SiteURL}/api/oauth/{OAuthAppName}/token
Authorization Code
Refresh Token
Client Credentials
POST /api/oidc/MyApp/token HTTP/1.1
Host: your-app.hub.loginradius.com
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVDIiwiY3J2IjoiUC0yNTYiLCJ4IjoiLi4uIiwieSI6Ii4uLiJ9fQ...
grant_type=authorization_code
&code=AUTHORIZATION_CODE
&redirect_uri=https%3A%2F%2Fyour-app.com%2Fcallback
&client_id=YOUR_CLIENT_ID
&code_verifier=YOUR_CODE_VERIFIER
A refresh of a DPoP-bound token stays bound to the same jkt. Send a new proof on every refresh.
POST /api/oidc/MyApp/token HTTP/1.1
Host: your-app.hub.loginradius.com
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7fX0...
grant_type=refresh_token
&refresh_token=REFRESH_TOKEN
&client_id=YOUR_CLIENT_ID
POST /api/oidc/MyApp/token HTTP/1.1
Host: your-app.hub.loginradius.com
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7fX0...
grant_type=client_credentials
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
Proof JWT
Header and payload of a token-endpoint proof:
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "...",
"y": "..."
}
}
{
"jti": "unique-proof-id",
"htm": "POST",
"htu": "https://your-app.hub.loginradius.com/api/oidc/MyApp/token",
"iat": 1778675881
}
| Claim / header | Required | Description |
|---|---|---|
typ | Required | Must be dpop+jwt. |
alg | Required | ES256 or RS256. |
jwk | Required | Public JWK only. Private parameters (d, p, q, and similar) are rejected. |
jti | Required | Unique proof ID. Reuse of the same jti for the same htu and jkt is rejected. |
htm | Required | HTTP method of this request (POST on /token). |
htu | Required | HTTP URI of this request: scheme, host, and path only. |
iat | Required | Unix timestamp. Proofs older than 120 seconds are rejected. |
nonce | When nonce is required | Value from the DPoP-Nonce response header. See Nonce challenge. |
ath | Resource requests only | Base64url SHA-256 of the ASCII access token. Omit on /token. |
Token response
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "DPoP",
"expires_in": 3600,
"refresh_token": "def50200..."
}
Decoded access token (DPoP-relevant claims):
{
"iss": "https://your-app.hub.loginradius.com/service/oidc/MyApp",
"sub": "e2a19b161ba241559319dc4c47e5ef75",
"azp": "your-client-id",
"cnf": {
"jkt": "YOUR_JWK_THUMBPRINT"
},
"iat": 1778675881,
"exp": 1778679481
}
Present this access token with Authorization: DPoP <token>, not Bearer. Encrypted (JWE) access tokens carry cnf.jkt inside the ciphertext. Resource servers that decrypt the JWE still enforce DPoP.
See Access Token Claims for the full claim set.
Step 3: Resource request
Every call that uses a DPoP-bound access token sends a new proof. The proof ath claim is the hash of that access token. The Authorization scheme is DPoP.
GET /service/oidc/MyApp/userinfo HTTP/1.1
Host: your-app.hub.loginradius.com
Authorization: DPoP eyJhbGciOiJSUzI1NiIs...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7fX0...
LoginRadius enforces this on its own resource endpoints, including:
| Resource | Method and path | Notes |
|---|---|---|
| OIDC UserInfo | GET /service/oidc/{OIDCAppName}/userinfo | Use Authorization: DPoP plus a proof whose htu is the userinfo_endpoint from discovery. |
| Identity account | GET /identity/v2/auth/account | Same scheme and proof. htu is the Identity URL (scheme, host, and path, no query). Send the API Key as X-LoginRadius-ApiKey. |
A DPoP-bound token presented as Authorization: Bearer or as ?access_token= is treated as a downgrade. LoginRadius returns 401 and a WWW-Authenticate challenge:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: DPoP algs="ES256 RS256", error="invalid_dpop_proof", error_description="DPoP-bound access token must be presented with the DPoP authorization scheme"
DPoP-Nonce: ServerIssuedNonceValue
DPoP-Nonce is included when Require DPoP nonce is on. Unbound Bearer tokens continue to use the Bearer scheme and do not require a proof.
Your own APIs must perform the same checks: if cnf.jkt is present, require Authorization: DPoP, validate the proof, and match jkt to cnf.jkt. See Access Token Claims.
Nonce challenge
When Require DPoP nonce is on, LoginRadius issues a DPoP-Nonce and expects that value in the next proof's nonce claim. This is the RFC 9449 challenge-response against proof replay.
- The client sends a token or resource request with a proof that has no nonce, or a stale nonce.
- LoginRadius responds with
use_dpop_nonce(token endpoint, HTTP 400) or401(resource) and setsDPoP-Nonce. - The client retries once with a new proof that includes
nonceequal to that header.
Token-endpoint nonces are scoped per client_id. A mismatched nonce returns use_dpop_nonce with the existing nonce. LoginRadius does not rotate the stored nonce on a bad request, so concurrent clients of the same app keep a stable challenge.
Cross-origin browser clients must be able to read DPoP-Nonce and WWW-Authenticate. LoginRadius exposes those headers in CORS (Access-Control-Expose-Headers).
If a nonce cannot be issued or looked up, LoginRadius rejects the request. It does not skip the nonce check.
Auth Studio
Auth Studio hosted login can start a DPoP authorization request for you. When the selected app has Enable DPoP (and when Require DPoP is on, the preview always includes it), the Admin Console preview URL adds dpopEnabled=true.
The v3 before-script reads dpopEnabled from the query string. Workflow URLs lowercase query keys, so the same flag is dpopenabled. The script then calls:
buildAuthorizeUrl({ pkce, dpop: true })
That call generates an ES256 key in the browser and adds dpop_jkt to /authorize. Token exchange sends the matching DPoP proof.
Keep the key in the same browser session as the authorization. A new tab without that key cannot mint proofs for a DPoP-bound token.
Error handling
Token-endpoint DPoP failures return OAuth JSON errors. Resource-server failures return 401 with WWW-Authenticate: DPoP.
| Scenario | HTTP status | error | Where |
|---|---|---|---|
| Missing or invalid proof when DPoP is required | 400 | invalid_dpop_proof | /token |
Proof jkt does not match the bound key | 400 | invalid_grant | /token (refresh and code exchange) |
| Missing or stale nonce | 400 | use_dpop_nonce | /token (retry with DPoP-Nonce) |
| DPoP-bound token sent as Bearer or query | 401 | invalid_dpop_proof | userinfo, Identity, your API |
| Valid token, invalid or missing resource proof | 401 | invalid_dpop_proof | userinfo, Identity, your API |
Replay of the same jti for the same htu and jkt | 400 or 401 | invalid_dpop_proof | /token or resource |
Token-endpoint example
{
"error": "use_dpop_nonce",
"error_description": "The DPoP nonce is invalid or expired"
}
HTTP/1.1 400 Bad Request
DPoP-Nonce: ServerIssuedNonceValue
Content-Type: application/json
Security considerations
DPoP reduces the impact of a leaked access token. It still depends on how you store the key, which algorithms you accept, and whether resource servers enforce cnf.jkt.
- Pair DPoP with PKCE on public clients. DPoP constrains the access token. PKCE protects the authorization code.
- Supported proof algorithms are
ES256andRS256. Symmetric algorithms andalg: noneare rejected. - Each proof is single-use for a given
htuandjkt(jtireplay window is 120 seconds). - Refresh stays bound to the original
jkt. A different key on refresh returnsinvalid_grant. - Do not put the DPoP private key in
localStorageor a cookie. Treat it like a device-bound credential. - Resource servers must check
cnf.jkt. LoginRadius Identity and userinfo already do. Your APIs must reject a DPoP-bound JWT presented as Bearer. - NonceRequired fail-closed. When nonce is required and the nonce store is unavailable, requests fail instead of skipping the check.