Identity for AI

Demonstrating Proof of Possession (DPoP) in the AuthPlayground

Binding tokens to the caller that holds the key.

Consider a retrieval agent running inside the enterprise AI platform. This AI workload’s job is to gather information needed by another agent or application. For example:

  • Planner agent: Requests the customer context needed for a task.

  • Retrieval agent: Retrieves the information.

  • Customer context API: Provides the data.

The retrieval agent might be authorized to access the customer context API under its own machine authority. There doesn’t need to be a human user involved.

Suppose the access token is copied from memory, exposed in a log, forwarded to another workload, or otherwise stolen. With a traditional bearer token, possession of the token might be enough to use it. DPoP (RFC 9449) is designed to solve this problem.

The DPoP model

DPoP binds an OAuth token to a cryptographic key and requires the caller to prove possession of the corresponding private key when using the token. With DPoP:

  • The caller holds both an access token and a private key.

  • The caller generates a DPoP proof using the private key.

  • The protected resource validates both the token and the proof.

A copied access token by itself isn’t enough. The caller must create a valid DPoP proof using the private key associated with the token. This is known as sender-constraining the token.

The AuthPlayground implementation

Entities involved: Client application, IdP (authorization server), resource server

The AuthPlayground demonstrates the Demonstrating Proof of Possession flow in four steps:

  1. Generate a DPoP key pair.

  2. Associate the authorization request with the DPoP key thumbprint.

  3. Exchange the code with a DPoP proof.

  4. Call the protected resource with DPoP.

Step 1: Generate a DPoP key pair

The client generates an asymmetric key pair locally. To demonstrate this, the playground uses RS256 and produces a public JSON Web Key (JWK) and its SHA-256 JWK thumbprint:

{
  "algorithm": "RS256",
  "publicJwk": {
    "e": "AQAB",
    "kty": "RSA",
    "n": "<modulus>"
  },
  "thumbprint": "VNJmvk1zqg80Gt7XiJalF8hru82ey-yCc5oOwQpBkew"
}

The thumbprint is commonly represented as the jkt. It gives the authorization server a way to associate the resulting access token with that key. To summarize the relationships:

  • Private key: Stays with the client and signs DPoP proofs.

  • Public key: Used to derive the JWK thumbprint.

  • JWK thumbprint: Represented as the jkt.

Step 2: Associate the authorization request with the DPoP key

The client begins the authorization flow. In the playground, the request includes PKCE and the DPoP key thumbprint:

GET /idp/authorize

response_type=code
client_id=playground-demo-client
redirect_uri=<callback>
scope=openid profile email
state=<state>
nonce=<nonce>
code_challenge=<pkce_challenge>
code_challenge_method=S256
dpop_jkt=VNJmvk1zqg80Gt7XiJalF8hru82ey-yCc5oOwQpBkew

The important DPoP parameter is dpop_jkt. This parameter associates the authorization request with the public key corresponding to the private key held by the client. The authorization server then returns an authorization code.

The client hasn’t sent its private key to the authorization server yet. It will prove possession of that key cryptographically in the next step.

Step 3: Exchange the code with a DPoP proof

The client sends the authorization code to the token endpoint. The request includes the normal Authorization Code and PKCE parameters and adds another HTTP header:

DPoP: <dpop_proof_jwt>

The DPoP proof is a JWT signed with the private key generated in Step 1. A simplified proof for the token request contains:

{
  "htm": "POST",
  "htu": "https://.../idp/token",
  "jti": "<unique_id>",
  "iat": "<timestamp>"
}

These claims matter because they bind the proof to the specific HTTP request:

  • htm: Identifies the HTTP method (such as POST).

  • htu: Identifies the target URI (for example, https://…​/idp/token).

  • iat: Indicates when the proof was created.

  • jti: Gives the proof a unique identifier.

The proof is a statement that the client possesses the private key and that the proof is created for a particular HTTP request.

The authorization server binds the token to the key

After validating the authorization code, PKCE verifier, and DPoP proof, the authorization server issues an access token. The response includes the access token and identifies it as a DPoP-bound token:

{
  "token_type": "DPoP",
  "expires_in": 3600,
  "access_token": "<access_token>"
}

The decoded access token contains several claims:

{
  "sub": "demo-user@example.com",
  "scope": "openid profile email",
  "client_id": "playground-demo-client",
  "cnf": {
    "jkt": "VNJmvk1zqg80Gt7XiJalF8hru82ey-yCc5oOwQpBkew"
  }
}

The important claim is the cnf object. This jkt matches the thumbprint of the key generated in Step 1. To summarize the relationships:

  • DPoP key pair: Derives the JWK thumbprint.

  • JWK thumbprint: Becomes the cnf.jkt claim.

  • DPoP-bound access token: Contains the cnf.jkt claim.

The access token is now associated with this key. Possessing the access token alone isn’t enough to satisfy DPoP validation.

Step 4: Call the protected resource

The client calls the resource server. The request contains both the DPoP-bound access token and a fresh DPoP proof:

GET /rs/api/data

Authorization: DPoP <access_token>
DPoP: <fresh_dpop_proof>

Notice that the authorization scheme is Authorization: DPoP rather than Authorization: Bearer.

The DPoP proof is freshly created for the resource request. A simplified version contains:

{
  "htm": "GET",
  "htu": "https://.../rs/api/data",
  "jti": "<unique_id>",
  "iat": "<timestamp>",
  "ath": "<access_token_hash>"
}

The additional ath claim is a hash of the access token being presented. This allows the proof to be associated with the particular access token used in the request. As a result, the resource server can verify several relationships before granting access.

  • The resource server checks that the DPoP proof:

    • Is signed by the expected key

    • Has the correct htm and htu

    • Is sufficiently fresh

    • Its ath matches the access token

  • The access token’s cnf.jkt matches the key used to sign the DPoP proof.

If these checks succeed and the token is otherwise valid, the request can proceed. The response confirms that the access is granted to the protected resource.

Why a fresh proof is created for each request

The DPoP proof used at the token endpoint isn’t reused at the resource server. They’re different HTTP requests with different methods and target URIs. The client creates a fresh proof for each request and the server validates that proof against the specific request being made.

The token endpoint proof used at the token endpoint includes:

htm = POST
htu = /idp/token

The resource proof used at the resource server includes:

htm = GET
htu = /rs/api/data
ath = hash(access_token)

Request binding is an important part of DPoP. A proof created for one endpoint shouldn’t be reusable against another endpoint.

What if the access token is copied?

Return to the retrieval-agent example. Suppose the retrieval agent legitimately obtains a DPoP-bound token, but an unrelated workload gets a copy. The unrelated workload has the token, but doesn’t have the corresponding private key. Without the matching private key, the workload can’t produce a valid DPoP proof, and the resource server rejects the request. DPoP makes a copied token less useful to a party that doesn’t possess the associated private key.

How DPoP relates to other concepts

DPoP doesn’t require a human or delegation

DPoP can protect tokens used by various entities:

  • Autonomous AI agents

  • Backend services

  • Workloads acting for a user, such as Sarah

The example with Sarah involves delegated human authority, but DPoP doesn’t determine whose authority is behind an operation. DPoP works at the token use layer, regardless of the authority behind the token, to protect the credential used to perform an operation. An autonomous AI agent can use DPoP while operating entirely under its own machine authority.

DPoP isn’t workload identity

There’s an important distinction between DPoP and workload identity. Suppose the retrieval agent has a SPIFFE identity, for example spiffe://company.internal/agents/retrieval. SPIFFE / SVID answers which workload is running.

DPoP answers whether the presenter possesses the key associated with the credential. A DPoP key shouldn’t be treated as the workload’s identity. The two mechanisms complement each other.

DPoP and PKCE protect different things

PKCE protects authorization-code redemption during the token request. It helps ensure that an intercepted authorization code can’t be redeemed without the corresponding verifier. DPoP sender-constrains the resulting access token during the resource request. The protocols protect different stages of the OAuth flow.

Key takeaways

The playground demonstrates the DPoP model from beginning to end:

  • The client generates a key pair, and its public-key thumbprint is associated with the authorization request through dpop_jkt.

  • At the token endpoint, the client proves possession of the private key, and the authorization server issues a DPoP-bound access token containing the corresponding cnf.jkt.

  • When the client calls the resource server, it creates a fresh DPoP proof tied to the HTTP request and the access token.

This gives the resource server cryptographic evidence that the presenter possesses the key associated with that token.

This is important for distributed AI systems. Agents, gateways, applications, and other workloads might all need OAuth tokens. DPoP helps ensure that copying a token isn’t enough for another caller to use it.