DPoP (Demonstrating Proof of Possession)#

DPoP is an application-level mechanism for sender-constraining OAuth tokens. Instead of a bearer token, which can be used by anyone who obtains it, a DPoP-bound token can only be used by the party that holds the private key the token was bound to. The client proves possession of that key by attaching a DPoP proof — a short-lived signed JWT — to its requests.

Like Mutual TLS certificate binding, DPoP is a Proof-of-Possession mechanism that prevents a leaked or stolen token from being replayed by another party. Unlike Mutual TLS, DPoP does not require client certificates or any special transport-layer setup, which makes it usable by public clients such as mobile applications and SPAs, and in deployments where managing a client PKI is not practical.

Since version 11.4, the Curity Identity Server implements the RFC 9449, OAuth 2.0 Demonstrating Proof of Possession (DPoP) specification for all client types. In earlier versions, DPoP was only used to protect the Hypermedia Authentication API — see HAAPI Access Control .

How DPoP Works#

The client generates an asymmetric key pair and includes a DPoP proof in the DPoP HTTP header of its token request. The proof is a JWT signed with the client’s private key. Its header carries the public key (jwk) and its payload identifies the exact request it was created for:

{
    "typ": "dpop+jwt",
    "alg": "ES256",
    "jwk": { "kty": "EC", "crv": "P-256", "x": "l8tFrhx...", "y": "9VE4jf_..." }
}
{
    "jti": "-BwC3ESc6acc2lTc",
    "htm": "POST",
    "htu": "https://idsvr.example.com/oauth/v2/token",
    "iat": 1750000000,
    "nonce": "eyJhbGciOiJ..."
}

When a valid proof accompanies the token request, the Curity Identity Server binds the issued tokens to the proof key by including the jkt confirmation claim — the SHA-256 thumbprint of the public key — in the token, and the token response carries "token_type": "DPoP" instead of "token_type": "bearer".

Claims of a DPoP-bound access token, as returned by introspection:

{
    "sub": "dpop_client",
    "purpose": "access_token",
    "iss": "https://idsvr.example.com/oauth/v2/oauth-anonymous",
    "active": true,
    "token_type": "DPoP",
    "client_id": "dpop_client",
    "aud": "dpop_client",
    "nbf": 1750000000,
    "scope": "read",
    "cnf": {
        "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"
    },
    "exp": 1750000300,
    "delegationId": "d0d2bc6b-55f9-4412-83bc-a42b992e15c1",
    "iat": 1750000000
}

A resource server that receives such a token must verify that the caller possesses the bound key: the token is sent using the DPoP authorization scheme (not Bearer), together with a fresh proof whose ath claim contains the hash of the access token. This is described in Using a DPoP-Bound Access Token .

DPoP at the Token Endpoint#

Presenting a DPoP proof at the token endpoint is always possible — no configuration is needed for a client to opt in. When a valid proof is present, the issued tokens are bound to the proof key according to the rules in Which Tokens Are Bound . Configuration is only needed to make DPoP mandatory for a client, or to change which tokens a public client gets bound.

The following grants support DPoP binding of the issued tokens:

  • Authorization Code
  • Refresh Token
  • Client Credentials
  • Resource Owner Password Credentials
  • Backchannel Authentication (CIBA)
  • Token Exchange (with its own rules — see Token Exchange and DPoP)
  • Pre-Authorized Code (Verifiable Credential Issuance)

The Device Authorization grant and the assertion grant do not support DPoP: the tokens they issue are never DPoP-bound, even when a proof is presented. Note that a client whose token-dpop-binding is required must still present a valid proof on every token request, these grants included.

Server-Provided Nonce#

The Curity Identity Server always requires a server-provided nonce in DPoP proofs (RFC 9449 section 8) — both at the token endpoint and when a DPoP-bound access token is used at resource endpoints, such as the userinfo endpoint. This limits how long a pre-generated proof can remain usable. The nonce exchange is handled transparently by DPoP client libraries:

  1. The client sends its request with a DPoP proof that has no (or a stale) nonce claim.
  2. The server rejects the request with the error code use_dpop_nonce and supplies a fresh nonce in the DPoP-Nonce response header.
  3. The client retries the request with a new proof that includes the received nonce in its nonce claim.

The nonce remains fresh for a while, so subsequent requests do not need the extra round trip until the server signals a new nonce.

Requiring DPoP#

By default DPoP is voluntary (token-dpop-binding set to client-requested): a token request without a proof simply results in bearer tokens. To enforce sender-constraining, set token-dpop-binding to required, either on the Token Profile as a default for all clients, or on an individual client as an override. A token request without a valid DPoP proof is then rejected with the error code invalid_dpop_proof.

Which Tokens Are Bound#

When DPoP is used — that is, when a valid proof accompanies the token request — which of the issued tokens get DPoP-bound depends on the client type, following RFC 9449 section 5:

Client typeAccess tokenRefresh token
ConfidentialBoundNot bound
PublicBound, unless configured to refresh-token-onlyBound
HAAPINot bound (see HAAPI token binding )Bound
  • A confidential client’s refresh token is already sender-constrained by client authentication at the token endpoint, so DPoP-binding it would add no security. Only the access token is bound.
  • A public client has no client credential, so its refresh token must always be sender-constrained when DPoP is used. The public-client-tokens-to-bind-with-dpop setting controls whether the access token is bound as well (access-token-and-refresh-token, the default) or stays a bearer token (refresh-token-only). Binding only the access token is not a legal option for a public client and is therefore not offered.
  • A HAAPI client uses DPoP to protect the authentication API itself; its OAuth access tokens remain bearer tokens. HAAPI clients have their own DPoP settings in the HAAPI capability, and the generic DPoP settings described on this page do not apply to them.

Authorization Code Binding#

The authorization code itself can also be DPoP-bound (RFC 9449 section 10). The client includes the dpop_jkt parameter — the base64url-encoded SHA-256 thumbprint of its DPoP public key — in the authorization request. The issued authorization code is then bound to that key, and the token request redeeming the code must carry a DPoP proof of the same key: redeeming a bound code without a proof is rejected with invalid_dpop_proof, and with a proof of a different key with invalid_grant.

The dpop_jkt parameter is also accepted in pushed authorization requests (PAR).

Code binding is optional by default (authorization-code-dpop-binding set to client-requested): the code is bound only when the client supplies dpop_jkt. To enforce it, set authorization-code-dpop-binding to required — authorization requests without dpop_jkt are then rejected with the invalid_request error code. The setting is available as a profile-wide default on the profile’s code client capability, and per client in the client’s dpop section, where a value other than inherit-from-profile may only be set on a client with the Authorization Code capability.

HAAPI flows bind the authorization code differently: the binding key is taken from the attested HAAPI flow and the binding is enabled with the issue-token-bound-authorization-code setting of the HAAPI capability — see HAAPI Clients.

Refreshing DPoP-Bound Tokens#

When a refresh token is DPoP-bound, every refresh-token grant request must include a DPoP proof for the same key the refresh token was bound to:

  • a refresh request without a proof is rejected with invalid_dpop_proof,
  • a refresh request with a proof for a different key is rejected with invalid_grant.

The binding is sticky: refresh tokens issued by the refresh-token grant keep the original binding, also when refresh-token rotation is in effect. The client cannot migrate a delegation to a new key by presenting a different proof. The access tokens minted on refresh are bound according to the server configuration rules active at the refresh time. It means that the policy and therefore access token binding may be modified after creating a delegation.

Token Exchange and DPoP#

Token exchange disposes of the subject token’s binding freely: the tokens resulting from an exchange are bound to the key proven by the DPoP proof presented on the exchange request itself, or issued as bearer tokens when the exchange request carries no proof. The exchanging client does not have to prove possession of the subject token’s bound key.

The exchanging client can therefore remove the DPoP binding of a token (by exchanging a bound token without a proof) or re-bind the result to its own, different key.

Because token exchange can strip or replace a token’s sender-constraint, grant the token-exchange capability only to highly privileged, trusted clients in deployments that rely on DPoP.

Using a DPoP-Bound Access Token#

A DPoP-bound access token is accepted by the protected endpoints of the Curity Identity Server itself — for example the userinfo endpoint, the SCIM API and the GraphQL APIs. The requirements, per RFC 9449 section 7, are:

  • The access token must be sent with the DPoP authorization scheme instead of Bearer.
  • The request must carry a fresh DPoP proof in the DPoP header, signed with the bound key, whose ath claim contains the base64url-encoded SHA-256 hash of the access token, and whose htm/htu claims match the request.
  • The proof must carry a fresh server-provided nonce; a request without one is challenged with 401, the use_dpop_nonce error code and a nonce in the DPoP-Nonce response header, as described in Server-Provided Nonce . A nonce previously obtained from the token endpoint may be reused here.
GET /oauth/v2/userinfo HTTP/1.1
Host: idsvr.example.com
Authorization: DPoP eyJhbGciOiJSUzI1NiIs...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs...

Presenting a DPoP-bound token with the Bearer scheme, without a proof, or with a proof for a different key is rejected with 401 and the invalid_token error code. Resource servers of your own APIs should apply the same checks; the cnf.jkt claim needed for the verification is available in the JWT access token and from the introspection endpoint, as shown above.

The introspection and revocation endpoints are not DPoP-protected: a DPoP-bound token can be introspected and revoked without presenting a proof. Introspection reports "token_type": "DPoP" and the cnf.jkt confirmation claim for bound tokens, so resource servers using introspection can perform the proof check themselves.

Combining DPoP with Mutual TLS#

A token carries at most one confirmation binding in its cnf claim: either a certificate binding (cnf.x5t#S256, from Mutual TLS ) or a DPoP key binding (cnf.jkt) — never both. When a client authenticates with Mutual TLS and at the same time presents a DPoP proof on the token request, DPoP takes precedence: the issued token is DPoP-bound only. A token whose cnf claim nevertheless carries more than one binding is rejected when presented.

Configuration#

Token Profile Settings#

Profile-wide DPoP settings live in the dpop section of the Token Profile: /profiles/profile/settings/authorization-server/dpop .

proof-token-clock-skew

The maximum allowed clock skew, in seconds, when validating the time of a DPoP proof (in the future or in the past). Defaults to 10.

allowed-signing-algorithms

The asymmetric signing algorithms accepted for DPoP proofs. Either all (the default — all supported asymmetric algorithms are accepted: RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512, EdDSA) or an explicit list of algorithms. Symmetric algorithms and none are never accepted (RFC 9449 section 4.2). The accepted algorithms are advertised in the server metadata as dpop_signing_alg_values_supported. This setting is profile-wide and cannot be overridden per client.

token-dpop-binding

The default for clients that do not override it — one of:

  • client-requested (the default): tokens are DPoP-bound only when the client presents a proof at the token endpoint.
  • required: every token request must carry a valid DPoP proof and is otherwise rejected.

Does not affect HAAPI clients.

authorization-code-dpop-binding

The default for clients that do not override it — one of:

  • client-requested (the default): the authorization code is DPoP-bound only when the client supplies the dpop_jkt authorization request parameter.
  • required: every authorization request must carry dpop_jkt and is otherwise rejected.

Does not affect HAAPI clients. Being an authorization-code concern, this default is not located in the dpop section but on the profile’s code client capability: /profiles/profile/settings/authorization-server/client-capabilities/code .

public-client-tokens-to-bind-with-dpop

The default for public clients that do not override it: which tokens a public client gets DPoP-bound when DPoP is used — access-token-and-refresh-token (the default) or refresh-token-only. This value is only consulted for public clients; confidential clients always get only the access token bound.

Example profile configuration:

<authorization-server xmlns="https://curity.se/ns/conf/profile/oauth">
    <dpop>
        <proof-token-clock-skew>10</proof-token-clock-skew>
        <allowed-signing-algorithms>
            <signing-algorithm>ES256</signing-algorithm>
            <signing-algorithm>PS256</signing-algorithm>
        </allowed-signing-algorithms>
        <token-dpop-binding>client-requested</token-dpop-binding>
        <public-client-tokens-to-bind-with-dpop>access-token-and-refresh-token</public-client-tokens-to-bind-with-dpop>
    </dpop>
</authorization-server>

Per-Client Settings#

A client configured in the Token Profile can override the profile defaults in its own dpop section .

Each option is overridden individually: every option defaults to inherit-from-profile, so partial overrides are possible — the client overrides only the options it sets to a concrete value and follows the profile for the rest. A client override may be stricter or more lenient than the profile default.

  • token-dpop-bindinginherit-from-profile (the default), required or client-requested; as the profile setting above, for this client only.
  • authorization-code-dpop-bindinginherit-from-profile (the default), required or client-requested; a value other than inherit-from-profile may only be set on a client with the Authorization Code capability.
  • public-client-tokens-to-bind-with-dpopinherit-from-profile (the default), access-token-and-refresh-token or refresh-token-only; may only be set on a public client.

The dpop section must not be configured on a HAAPI client — HAAPI flows are governed by the DPoP settings of the HAAPI capability .

Example configuration of a public client that must present a DPoP proof on token requests, with only the refresh token bound, and following the profile’s default for the authorization code:

<client>
    <id>my-mobile-app</id>
    <no-authentication>true</no-authentication>
    <redirect-uris>https://client.example.com/cb</redirect-uris>
    <scope>read</scope>
    <capabilities>
        <code/>
    </capabilities>
    <dpop>
        <token-dpop-binding>required</token-dpop-binding>
        <public-client-tokens-to-bind-with-dpop>refresh-token-only</public-client-tokens-to-bind-with-dpop>
    </dpop>
</client>

DPoP for the Different Client Types#

The settings above apply to clients configured in the Token Profile (configuration clients). The other client types support DPoP as follows.

Database Clients#

Database Clients support the same DPoP settings as configuration clients through an optional dpop object in the client’s configuration, manageable like the rest of the Database Client settings — via the GraphQL API or the DevOps Dashboard. The GraphQL DPoPBinding object carries authorization_code_dpop_binding and token_dpop_binding (enum REQUIRED or CLIENT_REQUESTED) and public_client_tokens_to_bind_with_dpop (enum REFRESH_TOKEN_ONLY or ACCESS_TOKEN_AND_REFRESH_TOKEN). Overrides are per-option: leaving a field unset means the profile default applies for that option. As for configuration clients, the dpop object may not be combined with the HAAPI capability and the tokens-to-bind option may only be set on public clients.

Dynamic Clients (DCR)#

Non-templatized dynamically registered clients follow the profile’s DPoP defaults, which can be overridden for all non-templatized clients in the profile’s Dynamic Client Registration settings: the dpop section under dynamic-client-registration/non-templatized carries authorization-code-dpop-binding, token-dpop-binding (each inherit-from-profile, required or client-requested) and public-client-tokens-to-bind-with-dpop (inherit-from-profile, refresh-token-only or access-token-and-refresh-token), the last applying to non-templatized clients registered as public clients (token_endpoint_auth_method of none).

In addition, a registering client can use the standard RFC 9449 registration parameter dpop_bound_access_tokens. Registering with dpop_bound_access_tokens: true makes DPoP mandatory for the client: its token requests must carry a valid DPoP proof. A dynamically registered client may only make the configured DPoP policy stricter, never relax it — if the configuration requires DPoP binding, a registration with dpop_bound_access_tokens: false does not lift the requirement. The registration and management responses echo dpop_bound_access_tokens with the effective value: whether a DPoP proof is required on the client’s token requests.

Templatized dynamic clients follow the DPoP configuration of their template client, tightened by dpop_bound_access_tokens: true in the same way.

Ephemeral Clients#

Ephemeral Clients follow the profile’s DPoP defaults, which can be overridden for all ephemeral clients in the profile’s Ephemeral Clients settings: the dpop section carries the same three per-option overrides as a configuration client (authorization-code-dpop-binding, token-dpop-binding and public-client-tokens-to-bind-with-dpop, each defaulting to inherit-from-profile). Additionally, an ephemeral client that sets dpop_bound_access_tokens: true in its Client ID Metadata Document always gets DPoP-bound access tokens: DPoP becomes mandatory for the client, and for a public ephemeral client the access token is bound even if the configuration says refresh-token-only.

HAAPI Clients#

HAAPI clients use DPoP to protect the Hypermedia Authentication API itself: the HAAPI access token is always DPoP-bound, and the authorization code and refresh token resulting from a HAAPI flow can be bound to the same key. This is configured with the use-legacy-dpop and issue-token-bound-authorization-code settings of the HAAPI capability — see HAAPI Access Control and Authorization Code and Refresh Token Binding . The OAuth access tokens issued to HAAPI clients are not DPoP-bound. The profile-wide proof validation settings Token Profile Settings (clock skew and allowed algorithms) apply to HAAPI proofs as well, but the binding-policy settings on this page do not.

Server Metadata#

The accepted DPoP proof signing algorithms are advertised in the OAuth and OpenID Connect metadata documents (RFC 9449 section 5.1):

{
    "dpop_signing_alg_values_supported": ["ES256", "PS256"]
}

When allowed-signing-algorithms is set to all, all supported asymmetric algorithms are listed.

Was this helpful?