Skip to main content
Version: Latest (4.0.6)

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:

  1. Navigate to Integrations → Applications (or Settings → Applications) and select your application.
  2. In the Security Settings / App Settings section, locate DPoP Configuration.
  3. 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​

ClaimTypeRequiredDescription
jtistringYesUnique identifier for the proof (UUID). Used for replay detection.
htmstringYesThe HTTP request method in uppercase (POST, GET).
htustringYesThe HTTP target URI without query parameters and fragment (must match the requested endpoint).
iatnumberYesEpoch timestamp in seconds when the proof was created. Valid within a 5-minute window with a 10-second skew tolerance.
athstringRequired at RSBase64url-encoded SHA-256 hash of the ASCII access token string. Required on requests to protected resource servers; omitted at the authorization and token endpoints.
noncestringOptionalServer-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:

  1. When a refresh token is originally issued with a DPoP binding, its thumbprint is permanently associated with the token.
  2. Subsequent refresh requests must provide a valid DPoP header matching the original key thumbprint.
  3. If a public client attempts to use a DPoP-bound refresh token without a DPoP header, cidaas rejects the request with TOKN10055 (DPoP validation failed).
  4. 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:

  1. Take the ASCII representation of the access_token string.
  2. Compute the SHA-256 digest.
  3. 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:

  1. Validates the DPoP proof header: verifies the signature using the public key in jwk, and checks htm and htu.
  2. Checks that iat is within the freshness window and jti has not been replayed.
  3. Computes the SHA-256 hash of the received access token, base64url-encodes it, and asserts that it matches ath in the proof payload.
  4. Validates the access token signature and extracts cnf.jkt.
  5. Verifies that the SHA-256 thumbprint of the jwk in the DPoP proof header matches cnf.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) and jti replay tracking without issuing mandatory server nonces. Resource servers implementing DPoP verification may optionally choose to issue their own DPoP-Nonce headers 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)​

CodeHTTP StatusMeaningActionable Remediation
AUTH10064400Non-DPoP request made to DPoP enforced clientProvide a valid dpop_jkt parameter in the request for applications configured to require DPoP.
AUTH10065400DPoP feature is not enabled for tenantContact cidaas support to enable the DPoP feature for the tenant.
AUTH10066400DPoP proof is required but missingPass a valid DPoP proof in the request header when calling /authz-srv/par.
AUTH10067400DPoP validation failedEnsure the DPoP proof is a well-formed JWS with valid typ, alg, and jwk headers, and matching htm/htu claims.
AUTH10068400DPoP header ignored (feature not enabled)Tenant configuration does not have DPoP enabled. Enable DPoP before passing DPoP headers.
AUTH10069400Invalid DPoP key bindingIn 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)​

CodeHTTP StatusMeaningActionable Remediation
TOKN10053400DPoP proof is required but missingProvide a valid DPoP proof header for applications configured with dpop_bound_access_tokens.
TOKN10054400DPoP feature not enabled for tenantContact cidaas support to enable the DPoP feature for your tenant before requesting DPoP tokens.
TOKN10055400DPoP validation failedCheck that the DPoP header contains a valid compact JWS string, matching htm/htu, valid iat, and an unused jti.
TOKN10056400Invalid DPoP key bindingEnsure the public key in the DPoP header matches the key originally bound to the authorization code or refresh token.
TOKN10057400DPoP bound auth code used for non-DPoP requestIf 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.