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.
The problem with bearer tokens
Section titled “The problem with bearer tokens”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.
How DPoP solves this
Section titled “How DPoP solves this”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
-
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).
-
Token request with proof — When exchanging the authorization code for tokens, the BFF includes a
DPoPheader containing a signed JWT proof. The proof includes the public key in the JWT header. -
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. -
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
cnfclaim.
The cnf claim
Section titled “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.
DPoP proof JWT structure
Section titled “DPoP proof JWT structure”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}| Claim | Required | Description |
|---|---|---|
jti | Yes | Unique token ID (prevents replay) |
htm | Yes | HTTP method of the request |
htu | Yes | HTTP URI of the request (no query string) |
iat | Yes | Issued-at timestamp |
exp | Yes | Expiration (30 seconds in Granit) |
Configuration
Section titled “Configuration”Server-side
Section titled “Server-side”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:
- Validates the proof JWT (signature,
htm,htu,exp) - Extracts the public key from the proof header
- Computes the JWK thumbprint and embeds it as the
cnfclaim - Returns
token_type: "DPoP"instead of"Bearer"
BFF integration
Section titled “BFF integration”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 ofBeareron proxied requests - Generates a fresh proof for each API request (method + URI bound)
- Includes DPoP proofs in token refresh requests
Resource server
Section titled “Resource server”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.
Bearer vs DPoP comparison
Section titled “Bearer vs DPoP comparison”Authorization: Bearer eyJhbG...
If stolen → attacker can replay freely until expiry.No way to distinguish legitimate client from attacker.Authorization: DPoP eyJhbG...DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs...
If token stolen → useless without the EC P-256 private key.Each request requires a fresh proof signed by the key holder.Security considerations
Section titled “Security considerations”| Threat | Without DPoP | With DPoP |
|---|---|---|
| Token stolen from logs | Full access until expiry | Useless — no private key |
| Token intercepted by proxy | Full replay capability | Proof required per request |
| XSS extracts token from SPA | Attacker has full token | N/A — BFF keeps tokens server-side |
| Compromised browser extension | Can read localStorage tokens | N/A — tokens never in browser |
| Token + key both stolen | N/A | Attacker has full access (defense in depth with short expiry) |
Key management
Section titled “Key management”| Aspect | Granit implementation |
|---|---|
| Algorithm | EC P-256 (ECDSA with SHA-256) |
| Key lifetime | Per BFF session (generated at login, destroyed at logout) |
| Storage | Server-side in BffTokenSet.DPoPPrivateKeyJwk (distributed cache) |
| Format | JWK JSON with private key parameter d |
| Proof lifetime | 30 seconds per proof JWT |
| Proof uniqueness | jti claim (random GUID per proof) |
Nonce support (§8) — replay protection
Section titled “Nonce support (§8) — replay protection”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:
- First request — sends a DPoP proof without nonce
use_dpop_nonceerror — server returns 400 with aDPoP-Nonceheader- Single retry — BFF rebuilds the proof with the server-provided nonce and retries
- Success — nonce is stored in
BffTokenSet.DPoPNonce(distributed cache) - 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.
FAPI 2.0 compliance
Section titled “FAPI 2.0 compliance”DPoP is mandatory in the FAPI 2.0 Security Profile alongside PAR and PKCE. The three mechanisms work together:
| Mechanism | Protects | RFC |
|---|---|---|
| PKCE | Authorization code interception | RFC 7636 |
| PAR | Authorization parameter tampering + PII leakage | RFC 9126 |
| DPoP | Token replay + theft | RFC 9449 |
Enable all three for maximum security:
{ "Bff": { "Frontends": [ { "Name": "admin", "UsePushedAuthorizationRequests": true, "UseDPoP": true } ] }}PKCE is enforced by default (RequireProofKeyForCodeExchange).
See also
Section titled “See also”- DPoP Resource Server Validation — IdP-agnostic proof validation for JWT Bearer (Keycloak, Entra ID)
- Private Key JWT Authentication — eliminate shared secrets
- PAR (Pushed Authorization Requests) — move parameters to back-channel
- FAPI 2.0 Security Profile — full conformance checklist
- OIDC Client Primitives —
IDPoPProofService, typed requests - OIDC Server Configuration — endpoints and flows overview
- BFF Security — token storage and encryption at rest
- BFF Configuration — frontend options reference