Skip to content

DPoP — Sender-Constrained Access Tokens (RFC 9449)

DPoP makes a stolen access token useless without the matching private key.

Rakomi implements RFC 9449 DPoP (Demonstrating Proof of Possession at the Application Layer). When you enable DPoP for an OAuth client, every access token issued to that client carries a cnf.jkt confirmation claim (RFC 7800) that binds the token to a client-held ephemeral key pair. Every request to a Rakomi resource-server endpoint must present a fresh DPoP-proof JWT signed by that key — a stolen bearer-string without the matching private key cannot forge a valid request.

DPoP defends against:

  • Token exfiltration via XSS, leaked log lines, browser-extension exfil
  • Network MITM that captures a token (TLS downgrade, hostile proxy)
  • Server-side store compromise where issued access tokens leak

DPoP does NOT defend against:

  • Full client compromise (the attacker has the private key)
  • Social-engineering of a fresh login flow
  • Server-side signing-key compromise
  • Refresh-token theft — refresh tokens are NOT DPoP-bound in this release. Binding them is on the roadmap. For the layers that DO cover refresh-token theft today, see the revocation epoch and refresh-token reuse detection.

DPoP is opt-in per OAuth client via the dpop_mode setting, with three states:

ModeBehavior
off (default)Legacy Bearer behavior — no behavioral change, no DPoP processing.
observeDPoP proof validated when present; absence is allowed and audited via auth.dpop_proof_absent_observe. Use this to validate your fleet’s DPoP readiness before flipping to required mode.
onDPoP proof REQUIRED on every token-endpoint POST and on every resource-server call.

Tenant admins flip the mode from the dashboard (/[tenant]/settings/dpop). For client_type='public' clients, only off and observe are available — RFC 9449 §5 mandates refresh-token binding for public clients with DPoP, and that work is explicitly deferred.

The SDK ships a createDpopProver helper that generates an ephemeral ECDSA P-256 keypair lazily on first use, then signs a fresh proof JWT on every request.

import { RakomiClient, createDpopProver } from '@rakomi/node';
const prover = createDpopProver({ baseUrl: 'https://api.rakomi.com' });
// On every token-endpoint POST:
const proof = await prover.proof('POST', '/oauth/token');
const tokenRes = await fetch('https://api.rakomi.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: proof },
body: new URLSearchParams({ grant_type: 'authorization_code', /* ... */ }),
});
const { access_token, token_type } = await tokenRes.json();
// token_type === 'DPoP' when the client has dpop_mode='on'
// On every resource-server call (note the `ath` binding):
const apiProof = await prover.proof('GET', '/v1/users/me', { accessToken: access_token });
await fetch('https://api.rakomi.com/v1/users/me', {
headers: { Authorization: `DPoP ${access_token}`, DPoP: apiProof },
});
  • The ephemeral keypair is process-scoped. Sharing the same prover across worker threads / processes voids the sender-constraint guarantee.
  • On serverless runtimes (Lambda, Vercel, CF Workers) the keypair lifetime matches the handler-instance lifetime. Expect token churn at cold-start frequency.
  • Algorithm allow-list. Rakomi accepts ES256 (ECDSA P-256) and EdDSA (Ed25519). RS256 is explicitly excluded — ephemeral RSA-2048 keys are bandwidth-heavy in the proof’s jwk header and RFC 9449 §11.4 implicitly assumes EC/OKP.
  • Refresh-token-public-vs-confidential split. Some peers (e.g. Keycloak) bind refresh tokens for public clients but not confidential. Rakomi defers refresh-token binding entirely in this release; confidential clients see Bearer refresh tokens identically to pre-DPoP behavior.

The discovery documents advertise DPoP support:

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

This field is emitted on /.well-known/oauth-authorization-server and /.well-known/openid-configuration.

Authorization-code binding (RFC 9449 §10)

Section titled “Authorization-code binding (RFC 9449 §10)”

A DPoP-aware client can bind its key to the authorization code at authorize- time by passing dpop_jkt=<thumbprint> as a query parameter. At the /oauth/token code-exchange the proof’s JKT MUST equal the stored value or the request is rejected as invalid_dpop_proof. This defends against the classic code-injection threat (OAuth Security BCP §4.10).

Not enabled yet. When server-issued nonces are enabled for a client, Rakomi responds to nonceless proofs with WWW-Authenticate: DPoP error="use_dpop_nonce", nonce="<value>" and the SDK transparently retries with the issued nonce — so a client built on the SDK needs no change to be ready for it. Handle that challenge in a hand-rolled client and you are ready too.