Skip to content

DPoP — Demonstrating Proof-of-Possession

DPoP (Demonstrating Proof-of-Possession) is an OAuth 2.0 extension defined in RFC 9449 that binds access tokens to a cryptographic key held by the client. Even if a token is stolen (via XSS, log exfiltration, or network interception), it cannot be replayed without the corresponding private key.

Standard bearer tokens are like cash — whoever holds them can spend them:

Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

If this token leaks (from logs, a compromised proxy, or a browser extension), any party can use it until it expires. There is no way to verify that the presenter is the legitimate recipient.

DPoP adds a proof-of-possession layer: the client generates an asymmetric key pair and proves ownership of the private key on every request.

sequenceDiagram
    participant BFF as BFF Server
    participant IdP as OpenIddict Server
    participant API as Resource Server

    Note over BFF: Generate EC P-256 key pair<br/>(once per session)

    BFF->>IdP: POST /connect/token<br/>+ DPoP: <proof JWT with public key>
    Note over IdP: Validate DPoP proof<br/>Embed cnf claim (JWK thumbprint)
    IdP-->>BFF: { access_token, token_type: "DPoP" }

    BFF->>API: GET /api/resource<br/>Authorization: DPoP <access-token><br/>DPoP: <fresh proof JWT>
    Note over API: Validate DPoP proof<br/>matches cnf in token
    API-->>BFF: 200 OK
  1. Key generation — The BFF generates an EC P-256 key pair when the user logs in. The private key is stored server-side (never exposed to the browser).

  2. Token request with proof — When exchanging the authorization code for tokens, the BFF includes a DPoP header containing a signed JWT proof. The proof includes the public key in the JWT header.

  3. Token binding — The OpenIddict server validates the DPoP proof, extracts the JWK thumbprint of the public key, and embeds it in the token’s cnf (confirmation) claim. The token is now bound to that key.

  4. API calls with proof — On every proxied request, the BFF generates a fresh DPoP proof JWT (signed with the same private key) and sends it alongside the access token. The resource server validates that the proof matches the cnf claim.

DPoP-bound tokens contain a cnf (confirmation) claim (RFC 7800) with the JWK thumbprint of the client’s public key:

{
"sub": "user-123",
"scope": "openid profile",
"cnf": {
"jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"
},
"exp": 1711152000,
"iss": "https://auth.example.com"
}

The jkt value is the base64url-encoded SHA-256 thumbprint of the client’s public JWK. The resource server computes the same thumbprint from the DPoP proof header and rejects the request if they don’t match.

Each DPoP proof is a short-lived JWT signed by the client:

Header:

{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "l8tFrhx-34tV3hRICRDY9zCa_IaVxA4kCmBKHNrPCEc",
"y": "4VqzKcjG1wJC_MijGhUKH6_7FJdMwF5wPBfGSYMHNbk"
}
}

Payload:

{
"jti": "a1b2c3d4e5f6",
"htm": "POST",
"htu": "https://auth.example.com/connect/token",
"iat": 1711148400,
"exp": 1711148430
}
ClaimRequiredDescription
jtiYesUnique token ID (prevents replay)
htmYesHTTP method of the request
htuYesHTTP URI of the request (no query string)
iatYesIssued-at timestamp
expYesExpiration (30 seconds in Granit)

No configuration needed. OpenIddict 7.x validates DPoP proofs automatically through its built-in ValidateProofOfPossession handler. When a client presents a DPoP proof during a token request, the server:

  1. Validates the proof JWT (signature, htm, htu, exp)
  2. Extracts the public key from the proof header
  3. Computes the JWK thumbprint and embeds it as the cnf claim
  4. Returns token_type: "DPoP" instead of "Bearer"

Enable DPoP on a frontend to automatically generate key pairs and attach proofs:

{
"Bff": {
"Frontends": [
{
"Name": "admin",
"UseDPoP": true
}
]
}
}

Or in code (e.g., in ShowcaseHostModule):

options.Frontends.Add(new BffFrontendOptions
{
Name = "admin",
ClientId = "my-bff",
ClientSecret = "...",
UseDPoP = true,
});

When enabled, the BFF:

  • Generates an EC P-256 key pair at login (stored server-side in BffTokenSet.DPoPPrivateKeyJwk)
  • Attaches a DPoP proof to the token exchange request (POST /connect/token)
  • Uses Authorization: DPoP <token> instead of Bearer on proxied requests
  • Generates a fresh proof for each API request (method + URI bound)
  • Includes DPoP proofs in token refresh requests

OpenIddict resource servers using AddGranitOpenIddictAuthentication() validate DPoP proofs automatically — the OpenIddict validation middleware handles both Bearer and DPoP token types transparently.

JWT Bearer resource servers (Keycloak, Entra ID, Auth0) need Granit.Authentication.DPoP for IdP-agnostic proof validation. See DPoP Resource Server Validation for the full setup guide.

Authorization: Bearer eyJhbG...
If stolen → attacker can replay freely until expiry.
No way to distinguish legitimate client from attacker.
ThreatWithout DPoPWith DPoP
Token stolen from logsFull access until expiryUseless — no private key
Token intercepted by proxyFull replay capabilityProof required per request
XSS extracts token from SPAAttacker has full tokenN/A — BFF keeps tokens server-side
Compromised browser extensionCan read localStorage tokensN/A — tokens never in browser
Token + key both stolenN/AAttacker has full access (defense in depth with short expiry)
AspectGranit implementation
AlgorithmEC P-256 (ECDSA with SHA-256)
Key lifetimePer BFF session (generated at login, destroyed at logout)
StorageServer-side in BffTokenSet.DPoPPrivateKeyJwk (distributed cache)
FormatJWK JSON with private key parameter d
Proof lifetime30 seconds per proof JWT
Proof uniquenessjti claim (random GUID per proof)

RFC 9449 §8 defines server-provided nonces for additional replay protection. When a server requires a nonce, the flow becomes:

sequenceDiagram
    participant BFF
    participant AS as Authorization Server

    BFF->>AS: POST /connect/token + DPoP proof (no nonce)
    AS-->>BFF: 400 use_dpop_nonce + DPoP-Nonce: abc123
    BFF->>AS: POST /connect/token + DPoP proof (nonce: abc123)
    AS-->>BFF: 200 OK + access_token + DPoP-Nonce: def456
    Note over BFF: Stores "def456" for next request

The BFF handles this automatically:

  1. First request — sends a DPoP proof without nonce
  2. use_dpop_nonce error — server returns 400 with a DPoP-Nonce header
  3. Single retry — BFF rebuilds the proof with the server-provided nonce and retries
  4. Success — nonce is stored in BffTokenSet.DPoPNonce (distributed cache)
  5. Subsequent requests — stored nonce is included in all DPoP proofs (proxy + refresh)

The nonce is updated whenever the server returns a new DPoP-Nonce header in token refresh responses.

DPoP is mandatory in the FAPI 2.0 Security Profile alongside PAR and PKCE. The three mechanisms work together:

MechanismProtectsRFC
PKCEAuthorization code interceptionRFC 7636
PARAuthorization parameter tampering + PII leakageRFC 9126
DPoPToken replay + theftRFC 9449

Enable all three for maximum security:

{
"Bff": {
"Frontends": [
{
"Name": "admin",
"UsePushedAuthorizationRequests": true,
"UseDPoP": true
}
]
}
}

PKCE is enforced by default (RequireProofKeyForCodeExchange).