Demonstrating Proof-of-Possession (DPoP)
DPoP (RFC 9449) binds OAuth 2.0 access and refresh tokens to a client-generated public/private key pair. Every request to the authorization server and protected resource servers carries a short-lived, cryptographically signed DPoP proof.
If an access or refresh token is intercepted in transit or exfiltrated from storage, it cannot be used alone without the corresponding client private key. While DPoP significantly raises the barrier against token theft, it does not prevent attacks where the client device itself is compromised (such as full private key exfiltration) or where resource servers fail to enforce sender constraints.
See Standards & Security Extensions for when to choose DPoP versus mTLS.
Key Benefits
- Sender-Constrained Tokens: Bearer tokens become holder-of-key credentials. Token exchange, token refresh, and protected resource requests require a valid proof signed by the client’s private key.
- No Client Certificates: Operates purely at the application layer over standard HTTPS. No mutual TLS handshake, custom certificate authorities, or infrastructure changes required.
- Public & Native Client Protection: Ideal for Single Page Applications (SPAs), mobile apps (iOS/Android), and CLI tools where client secrets cannot be securely stored.
- Full Flow Binding: Supports binding at the authorization stage (PAR or authorization query parameter), token issuance, token refresh, and resource access.
How It Works
Application Configuration
Enable DPoP binding for your application in the cidaas Admin Portal:
- Navigate to Integrations → Applications (or Settings → Applications) and select your application.
- In the Security Settings / App Settings section, locate DPoP Configuration.
- Enable DPoP-bound Access Tokens (
dpop_config.dpop_bound_access_tokens = true).
When configured via the App Configuration API, include:
{
"dpop_config": {
"dpop_bound_access_tokens": true
}
}
The DPoP Proof JWT
A DPoP proof is a signed JSON Web Signature (JWS) in compact serialization format, passed in the DPoP HTTP header.
Proof Header
The JOSE header must include the public key in JWK format (jwk) and the signature algorithm (alg):
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "f83OJ3D2xFWiL4Mm-UwEmUnUIDmxLbdUdETab58ddq8",
"y": "x_daQgahOz0wRSbiL05hudFqoLi-hkj2FDhuSDWBqzk"
}
}
Computing the JWK Thumbprint (jkt)
Per RFC 7638, the JWK SHA-256 thumbprint (jkt) is calculated by creating a canonical JSON string containing only the required key members in lexicographical order (crv, kty, x, y for EC keys), computing the SHA-256 hash, and base64url-encoding the result without padding.
For the example JWK above:
- Canonical JSON:
{"crv":"P-256","kty":"EC","x":"f83OJ3D2xFWiL4Mm-UwEmUnUIDmxLbdUdETab58ddq8","y":"x_daQgahOz0wRSbiL05hudFqoLi-hkj2FDhuSDWBqzk"} - SHA-256 hash base64url-encoded:
odaSLbkOkbS5s92RvIWrQ4KowQxhDobFUMxqU-I_gxE
Proof Payload Claims
| Claim | Type | Required | Description |
|---|---|---|---|
jti | string | Yes | Unique identifier for the proof (UUID). Used for replay detection. |
htm | string | Yes | The HTTP request method in uppercase (POST, GET). |
htu | string | Yes | The HTTP target URI without query parameters and fragment (must match the requested endpoint). |
iat | number | Yes | Epoch timestamp in seconds when the proof was created. Valid within a 5-minute window with a 10-second skew tolerance. |
ath | string | Required at RS | Base64url-encoded SHA-256 hash of the ASCII access token string. Required on requests to protected resource servers; omitted at the authorization and token endpoints. |
nonce | string | Optional | Server-provided nonce if challenged by the authorization or resource server. |
Example payload for a request to /token-srv/token:
{
"jti": "b78b543d-0d65-4f36-b605-67c4ec26c710",
"htm": "POST",
"htu": "https://your-domain.cidaas.de/token-srv/token",
"iat": 1729000000
}
Authorization & PAR with DPoP
For interactive authorization code flows, clients can bind the authorization request to their DPoP public key using the JWK thumbprint (dpop_jkt).
Pushed Authorization Request (PAR)
When using PAR via POST /authz-srv/par, the client sends the DPoP header:
curl -X POST "https://{your-subdomain}.cidaas.de/authz-srv/par" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "DPoP: <DPoP-proof-jwt>" \
-d "client_id=your_client_id" \
-d "response_type=code" \
-d "redirect_uri=https://app.example.com/callback" \
-d "scope=openid profile" \
-d "code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" \
-d "code_challenge_method=S256"
If dpop_jkt is also supplied in the request body, cidaas verifies that the body parameter matches the SHA-256 JWK thumbprint computed from the DPoP header key.
Authorization Endpoint (/authz-srv/authz)
In standard redirect flows via GET /authz-srv/authz, the client includes dpop_jkt as a query parameter:
https://{your-subdomain}.cidaas.de/authz-srv/authz?
client_id=your_client_id&
response_type=code&
redirect_uri=https://app.example.com/callback&
scope=openid profile&
code_challenge=...&
code_challenge_method=S256&
dpop_jkt=odaSLbkOkbS5s92RvIWrQ4KowQxhDobFUMxqU-I_gxE&
state=xyz123
Requesting Tokens with DPoP
When exchanging an authorization code or credentials at POST /token-srv/token, send the DPoP header:
curl -X POST "https://{your-subdomain}.cidaas.de/token-srv/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "DPoP: <DPoP-proof-jwt>" \
-d "grant_type=authorization_code" \
-d "client_id=your_client_id" \
-d "code=SplxlOBeZQQYbYS6WxSbIA" \
-d "redirect_uri=https://app.example.com/callback" \
-d "code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
Response
The authorization server returns an access token with token_type: "DPoP":
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "DPoP",
"expires_in": 3600,
"refresh_token": "rt_987654321...",
"id_token": "eyJhbGciOiJSUzI1NiIs...",
"scope": "openid profile"
}
Access Token Claims
The generated JWT access token contains the client key thumbprint in the cnf (confirmation) claim:
{
"iss": "https://your-domain.cidaas.de",
"sub": "user_12345",
"aud": "https://api.example.com",
"cnf": {
"jkt": "odaSLbkOkbS5s92RvIWrQ4KowQxhDobFUMxqU-I_gxE"
},
"exp": 1729003600
}
Refresh Token Binding
For public clients, cidaas strictly enforces DPoP binding on refresh token rotation:
- When a refresh token is originally issued with a DPoP binding, its thumbprint is permanently associated with the token.
- Subsequent refresh requests must provide a valid
DPoPheader matching the original key thumbprint. - If a public client attempts to use a DPoP-bound refresh token without a
DPoPheader, cidaas rejects the request withTOKN10055(DPoP validation failed). - If the key thumbprint in the new proof does not match the original binding, cidaas rejects the request with
TOKN10056(invalid DPoP key binding).
curl -X POST "https://{your-subdomain}.cidaas.de/token-srv/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "DPoP: <new-dpop-proof-jwt>" \
-d "grant_type=refresh_token" \
-d "client_id=your_client_id" \
-d "refresh_token=rt_987654321..."
Calling Protected Resource Servers
When calling protected APIs, the client supplies both the Authorization header and the DPoP header:
curl -X GET "https://api.example.com/user/profile" \
-H "Authorization: DPoP eyJhbGciOiJSUzI1NiIs..." \
-H "DPoP: <DPoP-proof-jwt-for-resource-endpoint>"
Access Token Hash (ath)
Per RFC 9449 Section 4.2, DPoP proofs that accompany an access token to a protected resource must include the ath (access token hash) claim. This binds the proof to that specific access token, preventing token substitution attacks.
Computing ath:
- Take the ASCII representation of the
access_tokenstring. - Compute the SHA-256 digest.
- Base64url-encode the digest without padding.
Example proof payload for resource access:
{
"jti": "e4b2d1c0-5a3f-4e67-9c81-2b4a6d8e0f12",
"htm": "GET",
"htu": "https://api.example.com/user/profile",
"iat": 1729000010,
"ath": "q7tLDRwkTPquVWPqSkrYKwBZodDkD_qD-DHQY643MPc"
}
Resource Server Verification Steps
When verifying incoming requests, the resource server:
- Validates the
DPoPproof header: verifies the signature using the public key injwk, and checkshtmandhtu. - Checks that
iatis within the freshness window andjtihas not been replayed. - Computes the SHA-256 hash of the received access token, base64url-encodes it, and asserts that it matches
athin the proof payload. - Validates the access token signature and extracts
cnf.jkt. - Verifies that the SHA-256 thumbprint of the
jwkin theDPoPproof header matchescnf.jkt.
Nonce Challenges (DPoP-Nonce)
RFC 9449 supports authorization server and resource server nonce challenges. When an authorization server or resource server provides a DPoP-Nonce HTTP header (or returns HTTP 401 Unauthorized with use_dpop_nonce), the client must store the nonce and include it in the nonce claim of subsequent DPoP proofs.
Note: Today, cidaas token endpoints validate proof freshness using
iat(5-minute window with 10-second skew tolerance) andjtireplay tracking without issuing mandatory server nonces. Resource servers implementing DPoP verification may optionally choose to issue their ownDPoP-Nonceheaders to enforce stricter clock and replay boundaries.
Token Introspection
When introspecting a DPoP-bound token at POST /token-srv/introspect, cidaas includes confirmation details:
{
"active": true,
"scope": "openid profile email",
"client_id": "your_client_id",
"sub": "user_12345",
"exp": 1729003600,
"iat": 1729000000,
"token_type": "DPoP",
"cnf": {
"jkt": "odaSLbkOkbS5s92RvIWrQ4KowQxhDobFUMxqU-I_gxE"
}
}
Error Codes
The following customer-observable error codes may be returned during DPoP validation:
Authorization & PAR Errors (/authz-srv)
| Code | HTTP Status | Meaning | Actionable Remediation |
|---|---|---|---|
AUTH10064 | 400 | Non-DPoP request made to DPoP enforced client | Provide a valid dpop_jkt parameter in the request for applications configured to require DPoP. |
AUTH10065 | 400 | DPoP feature is not enabled for tenant | Contact cidaas support to enable the DPoP feature for the tenant. |
AUTH10066 | 400 | DPoP proof is required but missing | Pass a valid DPoP proof in the request header when calling /authz-srv/par. |
AUTH10067 | 400 | DPoP validation failed | Ensure the DPoP proof is a well-formed JWS with valid typ, alg, and jwk headers, and matching htm/htu claims. |
AUTH10068 | 400 | DPoP header ignored (feature not enabled) | Tenant configuration does not have DPoP enabled. Enable DPoP before passing DPoP headers. |
AUTH10069 | 400 | Invalid DPoP key binding | In PAR requests, ensure the dpop_jkt body parameter matches the SHA-256 thumbprint of the key in the DPoP header. |
Token Endpoint Errors (/token-srv)
| Code | HTTP Status | Meaning | Actionable Remediation |
|---|---|---|---|
TOKN10053 | 400 | DPoP proof is required but missing | Provide a valid DPoP proof header for applications configured with dpop_bound_access_tokens. |
TOKN10054 | 400 | DPoP feature not enabled for tenant | Contact cidaas support to enable the DPoP feature for your tenant before requesting DPoP tokens. |
TOKN10055 | 400 | DPoP validation failed | Check that the DPoP header contains a valid compact JWS string, matching htm/htu, valid iat, and an unused jti. |
TOKN10056 | 400 | Invalid DPoP key binding | Ensure the public key in the DPoP header matches the key originally bound to the authorization code or refresh token. |
TOKN10057 | 400 | DPoP bound auth code used for non-DPoP request | If the authorization code was bound to a DPoP key during authorization, include the matching DPoP header at the token endpoint. |
SDK Integration
Official cidaas client SDKs support DPoP binding. For example, in client-side applications using the cidaas SDK:
// Enable DPoP for the client instance
cidaas.useDPoP();
When enabled, the SDK automatically manages key pair generation, stores the private key in secure client storage, generates signed DPoP proofs for authorization and token exchange, and binds tokens to the key thumbprint.