Syed Hasibur Rahman22 min read

OAuth DPoP: How Demonstrating Proof of Possession Makes Token Theft Boring

Bearer tokens are cash — whoever holds one can spend it. DPoP (RFC 9449, September 2023) replaces that model with sender-constrained tokens: the client keeps a private key, signs a fresh JWT for every request, and the resource server only honors the token if the signature matches the key the authorization server bound it to. This post unpacks the spec, walks through working code, and contrasts DPoP with mTLS and Token Binding.

There is a moment in every OAuth deployment where someone looks at the access token in a browser’s developer tools, copies it into curl, and realizes the unsettling truth: the token works from anywhere. From the legitimate single-page app. From a different machine. From a script running on the attacker’s laptop in another country. The token does not care who is holding it. That is what “bearer” means in bearer token, and it has been the load-bearing assumption of OAuth 2.0 since 20121.

For a decade the OAuth community has chipped away at that assumption with shorter token lifetimes, rotation, refresh-token binding, and PKCE. None of it changed the fundamental property — a stolen access token, while it lives, is fully usable by the thief.

RFC 9449, published in September 2023 by Fett, Campbell, Bradley, Lodderstedt, Jones, and Waite, finally fixes the model2. It is called DPoP — Demonstrating Proof of Possession at the Application Layer — and it does something almost embarrassingly simple in hindsight: it asks the client to prove, with a signature, that it still holds a private key the authorization server bound to the token at issuance. No private key, no API call. The bearer token becomes a sender-constrained token, and the economics of theft change in the attacker’s disfavor.

In January 2025 the IETF promoted this from a nice-to-have to a should-do. RFC 9700 — Best Current Practice for OAuth 2.0 Security — now states that authorization and resource servers SHOULD use sender-constraining mechanisms such as RFC 8705 mutual TLS or RFC 9449 DPoP to prevent misuse of stolen and leaked access tokens3.

This post is a ground-up walk through DPoP. We start with why bearer tokens are a liability, build the proof JWT field by field, run through what the server actually verifies, look at working JavaScript and Python implementations, then compare DPoP to its alternatives — mTLS, the failed Token Binding effort, and the older OAuth PoP draft. We close with security considerations the spec authors flag explicitly, where DPoP has and has not been adopted, and what to actually do on Monday morning.


Part 1: The bearer-token problem, stated honestly

OAuth 2.0 separates authorization from resource access. A client (your web app, your mobile app) sends the user to an authorization server, the user consents, and the authorization server hands back an access token the client then presents to the resource server (the API). RFC 6750 specifies that this access token is presented as Authorization: Bearer <token>1.

RFC 6750 is admirably blunt about the security model:

“Any party in possession of a bearer token (a ‘bearer’) can use the token to get access to the associated resources (without demonstrating possession of a cryptographic key).”1

That sentence is the entire problem. The token is its own credential. The TLS connection that delivered it is treated as the only sender-binding the system has — and once the token leaves that channel (logged on the server, cached in browser storage, written to a crash dump, exfiltrated from memory, shipped to a SaaS observability vendor’s pipeline) it is naked. Anyone who reads it can present it to the API and be indistinguishable from the legitimate user until expiry.

Practitioners have tried to outrun this with token hygiene. The OAuth 2.0 Security Best Current Practice (RFC 9700) catalogues most of the mitigations:

  • short access-token lifetimes (5–15 minutes for sensitive APIs)
  • refresh-token rotation
  • audience restrictions
  • DPoP-style sender constraint as the actual fix3

The first three buy time. They do not change the model. As Obsidian Security and others have repeatedly catalogued, real-world OAuth incidents from Microsoft 365 phishing chains to dependency-injection token leaks keep happening because the token, in flight or at rest, is still cash4.

The cleanest way to break the bearer model is to make the access token useless on its own. Pair every API call with a fresh proof that the caller holds a secret only the legitimate client knows. That is what DPoP does.


Part 2: What DPoP is, in one paragraph

The client generates a public/private key pair on startup. When it requests a token, it sends a JWT signed with the private key — and includes the public key inside that JWT’s header. The authorization server records the SHA-256 thumbprint of that public key (RFC 7638) and embeds it inside the issued access token in a cnf (confirmation) claim5. From that moment on, the access token is bound to that key. Every time the client calls the resource server, it sends a new JWT proof — same private key, fresh jti, current timestamp, the exact HTTP method and URI of the call — and the resource server verifies that the proof’s embedded public key thumbprint matches the cnf.jkt claim in the access token. If it doesn’t match, the request is rejected2.

A stolen access token, by itself, is now inert. To use it, an attacker would have to also steal the client’s private key — and DPoP encourages keeping that key non-extractable in the platform’s crypto store (WebCrypto, iOS Secure Enclave, Android Keystore, hardware tokens). The window of usability for a stolen-token-only attack collapses from “until expiry” to “never.”


Part 3: How DPoP works, end to end

Let us walk a full session, OAuth code-flow style, with DPoP applied.

Step 1 — The client generates a keypair

This happens once per client instance, usually at app startup. ES256 (ECDSA P-256 with SHA-256) is the de-facto default. The private key SHOULD be non-extractable.

// Browser / Node 22+ WebCrypto
const keyPair = await crypto.subtle.generateKey(
  { name: "ECDSA", namedCurve: "P-256" },
  false,                  // non-extractable private key
  ["sign", "verify"]
);

Step 2 — The client requests an access token

The token request goes to the authorization server’s /token endpoint. The client attaches a DPoP proof JWT in a header named DPoP. The proof’s htm is POST, its htu is the token endpoint URL, and its header carries the public key as a jwk:

POST /token HTTP/1.1
Host: as.example.com
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVD
      LCJjcnYiOiJQLTI1NiIsIngiOiJpQjlNVlBROERnVnYzWUtULU8tcDdMc1ZQYUVO
      ...
grant_type=authorization_code&code=...&code_verifier=...

The authorization server validates the proof, then issues an access token that is bound to the public key it just saw. Bindings live in the token’s cnf claim:

{
  "iss": "https://as.example.com",
  "sub": "user-1234",
  "exp": 1717865000,
  "cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" }
}

The jkt value is the base64url SHA-256 JWK thumbprint of the client’s public key, computed per RFC 76385. The authorization server also returns a token-type of DPoP (not Bearer)2.

Step 3 — The client calls the resource server

For every API call, the client builds a new DPoP proof. This time htm is the actual HTTP method (e.g. GET), htu is the resource URI, and a new claim — ath — carries the base64url-encoded SHA-256 hash of the access token, binding this specific proof to this specific token:

GET /resource/orders/42 HTTP/1.1
Host: api.example.com
Authorization: DPoP eyJhbGciOiJSUzI1NiIs...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs...

Note the Authorization scheme: DPoP, not Bearer. RFC 9449 defines a new HTTP authentication scheme so resource servers can challenge correctly2.

Step 4 — The resource server verifies

The resource server independently performs the chain:

  1. Parse the access token, extract cnf.jkt.
  2. Parse the DPoP proof header, extract the jwk.
  3. Compute the SHA-256 thumbprint of that jwk (RFC 7638) and check it equals cnf.jkt.
  4. Verify the DPoP JWT’s signature using that same jwk.
  5. Check typ == "dpop+jwt", alg is asymmetric and not none, htm matches the actual request method, htu matches the request URI (ignoring query and fragment), iat is within the acceptance window, and (when applicable) ath matches the SHA-256 of the access token actually presented.
  6. Reject replays — typically by tracking the jti for the lifetime of the proof window.

Only if every step passes is the request honored.

Step 6 above — jti replay tracking — works but it requires server-side state, which is expensive at scale. RFC 9449 offers an elegant alternative: the server can demand the proof include a nonce value the server itself chose. The server returns the nonce in a DPoP-Nonce HTTP header, often inside a 401 or 400 response with error="use_dpop_nonce". The client retries with nonce added to the proof claims2.

HTTP/1.1 401 Unauthorized
WWW-Authenticate: DPoP error="use_dpop_nonce",
   error_description="Authorization server requires nonce in DPoP proof"
DPoP-Nonce: eyJ7S_zG.eyJIO...

The nonce binds the proof’s freshness to a server-controlled clock, eliminating most replay windows without per-jti storage. The spec notes: “Server-provided nonces are an effective means for further reducing the chances for successful DPoP proof replay… by providing new nonce values at times of its choosing, the server can limit the lifetime of DPoP proofs.”2


Part 4: The DPoP proof JWT, field by field

Here is the full anatomy.

JOSE header

FieldRequiredValue
typyesMust be exactly "dpop+jwt". This prevents confusion with other JWT types — a server expecting a DPoP proof refuses anything else2.
algyesAn asymmetric signature algorithm: ES256, ES384, ES512, RS256, PS256, EdDSA. none and any MAC-only alg (e.g. HS256) are forbidden by the spec2.
jwkyesThe client’s public key as a JSON Web Key. The private counterpart never leaves the client.

Payload claims

ClaimRequiredMeaning
jtiyesUnique identifier for the proof. RFC 9449 recommends at least 96 bits of pseudorandom entropy or a UUID v4. Used for replay detection.
htmyesHTTP method of the target request (GET, POST, etc., uppercase).
htuyesHTTP target URI without query string or fragment. Example: https://api.example.com/resource/orders/42.
iatyesCreation timestamp, seconds since epoch. Servers reject proofs whose iat is too far in the past or future.
athwhen proof accompanies an access tokenBase64url-encoded SHA-256 hash of the access token. Binds this specific proof to this specific token. Without ath, a proof valid for one token could be reused with another.
noncewhen server demands oneThe most recent DPoP-Nonce value the server provided.

A decoded payload from a representative resource-server request looks like:

{
  "jti": "e1j3V_bKic8-LAEB",
  "htm": "GET",
  "htu": "https://api.example.com/resource/orders/42",
  "iat": 1717862100,
  "ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo",
  "nonce": "eyJ7S_zG.eyJIO..."
}

That payload, with the matching header, is signed by the client’s private key. The whole thing is short-lived by design: a typical acceptance window is 30–60 seconds for iat, and once the jti has been seen (or the nonce expires) the proof is dead.


Part 5: Why JWK thumbprint binding is the elegant bit

A naïve “PoP” design might embed the entire public key inside the access token. That is bulky and forces resource servers to validate key formats. RFC 9449 instead borrows RFC 7638’s JWK thumbprint: a deterministic, canonical SHA-256 hash of the public key5. The access token carries only the 32-byte (43-character base64url) jkt value:

"cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" }

The proof JWT carries the actual public key. The resource server recomputes the thumbprint from the proof’s jwk and compares strings. This means:

  • Resource servers do not need to know the client’s key in advance. They learn it from the proof and verify cryptographically that the authorization server bound that exact key.
  • Tokens stay small. 43 base64url characters is cheap.
  • Algorithm agility. Any key the client and server agree on works, because the comparison is at the thumbprint level.

The cnf claim itself is not new — RFC 7800 defined “Proof-of-Possession Key Semantics for JSON Web Tokens” back in 20166 — DPoP plugs into that pre-existing slot.


Part 6: A working JavaScript implementation

This is what a minimal but spec-conformant DPoP client looks like in browser-grade JavaScript with WebCrypto. I have deliberately avoided libraries to make the moving parts visible. In production you would use panva/dpop or jose, both of which wrap exactly this7.

// --- One-time setup ---
const keyPair = await crypto.subtle.generateKey(
  { name: "ECDSA", namedCurve: "P-256" },
  false,                         // non-extractable
  ["sign", "verify"]
);

// Export the public JWK once; we'll embed it in every proof header.
const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
delete publicJwk.ext; delete publicJwk.key_ops;   // strip non-essential fields

// --- Helpers ---
const b64url = (buf) =>
  btoa(String.fromCharCode(...new Uint8Array(buf)))
    .replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");

async function sha256(input) {
  const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input;
  return crypto.subtle.digest("SHA-256", bytes);
}

function randomJti() {
  const arr = new Uint8Array(16);
  crypto.getRandomValues(arr);
  return b64url(arr);
}

// --- Build a DPoP proof ---
async function makeDpopProof({ htm, htu, accessToken, nonce }) {
  const header = { typ: "dpop+jwt", alg: "ES256", jwk: publicJwk };
  const payload = {
    jti: randomJti(),
    htm,
    htu,
    iat: Math.floor(Date.now() / 1000),
  };
  if (accessToken) payload.ath = b64url(await sha256(accessToken));
  if (nonce)       payload.nonce = nonce;

  const signingInput =
      b64url(new TextEncoder().encode(JSON.stringify(header))) + "." +
      b64url(new TextEncoder().encode(JSON.stringify(payload)));

  const sigDer = await crypto.subtle.sign(
    { name: "ECDSA", hash: "SHA-256" },
    keyPair.privateKey,
    new TextEncoder().encode(signingInput)
  );

  // WebCrypto returns IEEE P1363 (r||s) for ECDSA — exactly what JWS wants.
  return signingInput + "." + b64url(sigDer);
}

// --- Use it ---
async function fetchWithDpop(url, accessToken, init = {}) {
  let nonce = sessionStorage.getItem("dpop_nonce");

  const doRequest = async () => {
    const proof = await makeDpopProof({
      htm: (init.method || "GET").toUpperCase(),
      htu: url.split("?")[0].split("#")[0],
      accessToken,
      nonce,
    });
    return fetch(url, {
      ...init,
      headers: {
        ...(init.headers || {}),
        "Authorization": `DPoP ${accessToken}`,
        "DPoP": proof,
      },
    });
  };

  let res = await doRequest();

  // Server demanded a (new) nonce — pick it up and retry once.
  if (res.status === 401 || res.status === 400) {
    const newNonce = res.headers.get("DPoP-Nonce");
    if (newNonce && newNonce !== nonce) {
      sessionStorage.setItem("dpop_nonce", newNonce);
      nonce = newNonce;
      res = await doRequest();
    }
  }
  return res;
}

Three things to flag:

  1. ext: false on generateKey means the private key never leaves the WebCrypto subsystem. You cannot export it. Even if an attacker pops your page with XSS, they cannot exfiltrate the key — they can only ask WebCrypto to sign on their behalf while the page is alive. This is a meaningful step up from localStorage-resident secrets.
  2. ECDSA signatures in WebCrypto are already in JWS-compatible r||s format. If you were doing this in Node’s older crypto API, you would have to convert from DER.
  3. Nonce loop. The first request goes without a nonce, the server replies with use_dpop_nonce and a DPoP-Nonce header, the client retries. From then on it caches the latest nonce and updates it whenever the server sends a new one.

Part 7: A working Python implementation

Server-side DPoP verification is the more common Python use case. Here is a minimal verifier using cryptography and pyjwt:

import base64, hashlib, json, time
from urllib.parse import urlsplit, urlunsplit
import jwt
from jwt import PyJWK
from jwt.algorithms import ECAlgorithm

ACCEPTED_ALGS = {"ES256", "ES384", "ES512", "PS256", "RS256", "EdDSA"}
PROOF_MAX_AGE = 60   # seconds
SEEN_JTIS = {}       # jti -> expiry; replace with Redis in production


def _b64url_no_pad(b: bytes) -> str:
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()


def _jwk_thumbprint(jwk: dict) -> str:
    # RFC 7638: canonical ordering for the key type.
    if jwk["kty"] == "EC":
        canonical = {"crv": jwk["crv"], "kty": "EC",
                     "x": jwk["x"], "y": jwk["y"]}
    elif jwk["kty"] == "RSA":
        canonical = {"e": jwk["e"], "kty": "RSA", "n": jwk["n"]}
    elif jwk["kty"] == "OKP":
        canonical = {"crv": jwk["crv"], "kty": "OKP", "x": jwk["x"]}
    else:
        raise ValueError("unsupported kty")
    serialized = json.dumps(canonical, separators=(",", ":"), sort_keys=False)
    return _b64url_no_pad(hashlib.sha256(serialized.encode()).digest())


def _canonical_htu(url: str) -> str:
    s = urlsplit(url)
    return urlunsplit((s.scheme, s.netloc, s.path, "", ""))


def verify_dpop(
    dpop_header: str,
    *,
    htm: str,
    htu: str,
    access_token: str,
    cnf_jkt: str,
    nonce: str | None = None,
) -> dict:
    # Decode header without verifying yet, to extract the jwk.
    unverified_header = jwt.get_unverified_header(dpop_header)

    if unverified_header.get("typ") != "dpop+jwt":
        raise ValueError("bad typ")
    if unverified_header.get("alg") not in ACCEPTED_ALGS:
        raise ValueError("alg not allowed")
    jwk = unverified_header.get("jwk")
    if not jwk:
        raise ValueError("missing jwk")

    # The key bound to the access token must match the proof's key.
    if _jwk_thumbprint(jwk) != cnf_jkt:
        raise ValueError("jwk thumbprint != cnf.jkt")

    # Now verify the signature using that jwk.
    key = PyJWK.from_dict(jwk).key
    claims = jwt.decode(
        dpop_header, key=key,
        algorithms=[unverified_header["alg"]],
        options={"verify_aud": False, "verify_iss": False, "verify_exp": False},
    )

    # Required claims.
    for name in ("jti", "htm", "htu", "iat"):
        if name not in claims:
            raise ValueError(f"missing {name}")

    if claims["htm"].upper() != htm.upper():
        raise ValueError("htm mismatch")
    if _canonical_htu(claims["htu"]) != _canonical_htu(htu):
        raise ValueError("htu mismatch")

    now = int(time.time())
    if abs(now - int(claims["iat"])) > PROOF_MAX_AGE:
        raise ValueError("iat outside acceptance window")

    expected_ath = _b64url_no_pad(hashlib.sha256(access_token.encode()).digest())
    if claims.get("ath") != expected_ath:
        raise ValueError("ath does not bind to access token")

    if nonce is not None and claims.get("nonce") != nonce:
        raise ValueError("nonce mismatch")

    # Replay protection.
    jti = claims["jti"]
    if SEEN_JTIS.get(jti, 0) > now:
        raise ValueError("jti replay")
    SEEN_JTIS[jti] = now + PROOF_MAX_AGE

    return claims

This is approximately what a production library like Authlete’s, Auth0’s, or Connect2id’s does on the verifier side — with cleaner state management for jti (Redis with TTL), proper key caching, and a constant-time hash comparison8.


Part 8: DPoP versus its alternatives

DPoP is one of three answers to the sender-constraining problem. The others are mTLS and the now-defunct Token Binding effort. The IETF community spent the better part of a decade comparing them; here is the short version.

Mutual TLS (RFC 8705)

Defined in 2020, RFC 8705 binds the access token to the TLS client certificate the client used at the token endpoint. The authorization server records the certificate’s SHA-256 thumbprint in the access token’s cnf.x5t#S256 claim. The resource server checks that the TLS certificate on the actual API call hashes to the same value9.

Strengths. Binding is at the transport layer — there is literally no way to use the token off the TLS channel that owns the certificate. mTLS is also a client authentication method (DPoP is not), and reuses your existing PKI when you have one.

Weaknesses. PKI is operationally heavy: certificate issuance, rotation, revocation, intermediate trust chains. Browsers and SPAs cannot easily manage client certificates. Reverse proxies must be configured to forward client-cert information end-to-end (X-SSL-Client-Cert or similar) without spoofing risk. For public mobile and SPA clients, mTLS is a non-starter10.

Where to use it. Confidential server-to-server clients with an existing PKI — open banking, FAPI 2.0 deployments, microservice meshes. Quarkus’s OIDC documentation explicitly recommends mTLS for confidential clients and DPoP for public clients11.

Token Binding (RFC 8471, RFC 8473)

Token Binding (2018) tried to bind tokens to a per-session keypair derived from the TLS connection itself, exposed to the application via a browser API12. It was elegant. It died — Microsoft Edge implemented it, Chrome removed support in 2018, and the IETF working group effectively abandoned it. RFC 9449 explicitly cites Token Binding’s failure as a motivator: “Token Binding… required browser support that did not materialize.”

DPoP is what you build when you cannot rely on the browser stack to do anything beyond WebCrypto and fetch.

OAuth 2.0 PoP (draft-ietf-oauth-pop-architecture, 2016)

An earlier, much messier proposal that introduced symmetric-key proof-of-possession. It never reached RFC status — the threat model, key distribution story, and JWS profile were all problematic. DPoP is the working-group’s eventual clean asymmetric replacement.

Summary table

PropertyBearer (RFC 6750)mTLS (RFC 8705)DPoP (RFC 9449)
Sender-constrained?NoYes (TLS cert)Yes (app-layer key)
Works for SPAs / mobile?YesNo (effectively)Yes
Requires PKI?NoYesNo
Per-request crypto costZeroTLS handshake amortizedOne sign + one verify
Stops token-only theft?NoYesYes
Stops infrastructure replay between server and gateway?NoYes (channel-bound)Partially (htu binding)
IETF statusStandards TrackStandards TrackStandards Track
RFC 9700 recommendationAvoidUse for confidential clientsUse for public clients

Part 9: Security considerations the spec authors flag

RFC 9449 §11 contains an unusually candid security-considerations section. The headline points:

DPoP is not authentication. A DPoP proof alone, without an access token, proves nothing useful. The proof tells the resource server “whoever made this request also signed it with this key” — but the resource server has no reason to trust that key unless an access token, issued by an authorization server it trusts, binds the same key via cnf.jkt. Implementations that try to use DPoP proofs as standalone credentials are mis-using the spec2.

Proof replay is the central risk. Two layered defenses:

  • jti tracking by the resource server, with the proof window (typically ≤60 s) defining the storage TTL. The spec warns: “in order to guard against memory exhaustion attacks, a server that is tracking jti values should reject DPoP proof JWTs with unnecessarily large jti values or store only a hash thereof.”2
  • Server-provided nonce via DPoP-Nonce. Eliminates the need for per-jti state across a horizontally scaled fleet (each node generates and rotates its own nonces) and tightly bounds proof lifetime. “Unlike cryptographic nonces, it is acceptable for clients to use the same nonce multiple times and for the server to accept the same nonce multiple times, as long as the jti value is tracked and duplicates are rejected for the lifetime of the nonce.”2

In practice, large deployments use both: a global server-issued nonce for freshness, plus per-node jti cache to catch replays inside the nonce window.

Algorithm confusion. Servers must validate typ == "dpop+jwt" strictly. Without this check, an attacker who learns the format could repurpose ID tokens or other JWTs as DPoP proofs2. Servers must also reject alg: none and any symmetric MAC.

htu normalization. Both client and server must normalize the URI identically — same scheme casing, same default-port elision, query and fragment stripped. RFC 9449 references RFC 3986 §6 for the normalization rules.

Key compromise. If the client’s private key is stolen, DPoP collapses to bearer semantics: the thief can both present the access token and sign valid proofs. This is why DPoP keys should live in non-extractable WebCrypto handles, mobile-platform secure enclaves, or hardware tokens.

Privacy and tracking. The same jkt thumbprint persists across token refreshes for the lifetime of the keypair, so the authorization server can correlate sessions. Clients SHOULD generate fresh keys per logical session if cross-session unlinkability matters.

ath is not optional. Resource-server-bound proofs without ath create a window where a proof captured on one resource call could be paired with a different access token. Always send ath when an access token is in play.


Part 10: Real-world adoption, as of mid-2026

DPoP went from RFC in September 2023 to recommended best practice in RFC 9700 (January 2025)3. The deployment graph since has been a steady ramp rather than a hockey stick.

Authorization servers shipping DPoP support:

  • Keycloak (26.4+) — per-client toggle, “Require DPoP bound tokens”13.
  • Auth0 / Okta — first-party SDK support and provider-side enforcement14.
  • Ping Identity — given Brian Campbell and David Waite co-authored the RFC, this was Day Zero.
  • Authlete — Daniel Fett is at Authlete; reference-grade implementation.
  • Connect2id — Nimbus OAuth SDK ships DPoP helpers8.
  • Microsoft Entra ID — DPoP support announced for protected-resource scenarios in late 2024.
  • Curity, ForgeRock, IBM Verify — all support DPoP via configuration15.

Standards profiles that have adopted DPoP:

  • FAPI 2.0 — the OpenID Foundation’s high-assurance Financial-grade API profile permits either mTLS or DPoP for sender constraint, with DPoP filling the SPA/mobile gap mTLS could never cover16.
  • OpenID Federation in education — institutions can pick DPoP or mTLS per participant capability17.

Where adoption is still slow:

  • Solid Project. The Solid community pushed back on DPoP early, arguing the threat model didn’t match their decentralized-pod architecture and that DPoP’s per-request cost was excessive for Solid’s I/O patterns. The technical objections in solid/authentication-panel#47 remain instructive reading on when not to apply DPoP18.
  • Long-tail SaaS APIs. Most consumer-facing APIs still issue bare bearer tokens. The friction is not the spec — it is changing every client SDK in the ecosystem.

The next inflection point is likely OAuth 2.1, the consolidation profile currently in IETF working-group last call as of early 2026, which folds RFC 9700 normatively and may upgrade sender constraint from “SHOULD” toward “SHOULD/MUST depending on profile.”


Part 11: What to actually do

If you operate an OAuth deployment and read this far, here is the short prescription:

  1. For public clients (SPAs, mobile, desktop): add DPoP. The cost is one crypto.subtle.generateKey at startup, one sign per request, and a 200-byte header. Your authorization server probably already supports it as a toggle.
  2. For confidential server-to-server clients with PKI in place: keep mTLS (or add it if you don’t have it). DPoP is not a downgrade from mTLS — it’s a peer technology for a different deployment shape.
  3. Even before you turn on DPoP issuance, turn on DPoP enforcement on resource servers for endpoints that handle anything regulated, financial, or PII. The resource server check is independent — it can reject any access token whose cnf.jkt does not match a presented proof, and clients can adopt DPoP incrementally.
  4. Use server-provided nonces from day one. The implementation cost is a single nonce-issuing endpoint and a DPoP-Nonce header on rejected requests. It removes the need for distributed jti tracking and tightens replay windows to single-digit seconds.
  5. Bind your DPoP keys to non-extractable storage. extractable: false in WebCrypto, Secure Enclave on iOS, Keystore on Android, TPM/HSM on servers. A DPoP key in localStorage is a DPoP key the next XSS exfiltrates.
  6. Treat refresh tokens the same way. RFC 9449 binds refresh tokens to the same key as access tokens. A stolen refresh token without the key is also inert.

Closing

The honest summary of OAuth 2.0’s first decade is that the community spent it agreeing that bearer tokens were a problem, then spent another half-decade arguing about the fix. mTLS was correct but operationally hostile. Token Binding was elegant but stranded by browser politics. The OAuth PoP drafts were a mess. DPoP, when it finally landed in September 2023, was the spec that worked because it asked for the minimum possible thing: one keypair, one short signed JWT per request, and a 43-character thumbprint in the token. Nothing more.

If your threat model includes “someone might steal an access token” — and every threat model does — sender-constraining tokens is no longer optional best practice. It is what RFC 9700 says authorization servers should do. DPoP is what makes that possible for the 90% of clients that cannot do mTLS.

Token theft, in 2026, can finally become boring. Boring is good. Boring means the attacker stole something that doesn’t work anymore.


References


  1. Jones, M. and Hardt, D., “The OAuth 2.0 Authorization Framework: Bearer Token Usage”, IETF RFC 6750, October 2012. https://www.rfc-editor.org/rfc/rfc6750 ↩︎ ↩︎ ↩︎

  2. Fett, D., Campbell, B., Bradley, J., Lodderstedt, T., Jones, M., and Waite, D., “OAuth 2.0 Demonstrating Proof of Possession (DPoP)”, IETF RFC 9449, September 2023. https://datatracker.ietf.org/doc/html/rfc9449 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  3. Lodderstedt, T., Bradley, J., Labunets, A., and Fett, D., “Best Current Practice for OAuth 2.0 Security”, IETF RFC 9700, January 2025. https://datatracker.ietf.org/doc/rfc9700/ ↩︎ ↩︎ ↩︎

  4. Obsidian Security, “OAuth Vulnerabilities Every Security Team Should Know”. https://www.obsidiansecurity.com/blog/oauth-vulnerabilities-security-teams ↩︎

  5. Jones, M. and Sakimura, N., “JSON Web Key (JWK) Thumbprint”, IETF RFC 7638, September 2015. https://www.rfc-editor.org/rfc/rfc7638 ↩︎ ↩︎ ↩︎

  6. Jones, M., Bradley, J., and Tschofenig, H., “Proof-of-Possession Key Semantics for JSON Web Tokens (JWTs)”, IETF RFC 7800, April 2016. https://www.rfc-editor.org/rfc/rfc7800 ↩︎

  7. Panva, F., “dpop — DPoP for Web Platform API JavaScript runtimes”, GitHub. https://github.com/panva/dpop ↩︎

  8. Connect2id, “OAuth 2.0 DPoP sender-constrained access tokens”. https://connect2id.com/products/nimbus-oauth-openid-connect-sdk/examples/oauth/dpop ↩︎ ↩︎

  9. Campbell, B., Bradley, J., Sakimura, N., and Lodderstedt, T., “OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens”, IETF RFC 8705, February 2020. https://www.rfc-editor.org/rfc/rfc8705 ↩︎

  10. SecureAuth, “Sender-Constrained Access Tokens: mTLS vs DPoP”. https://docs.secureauth.com/ciam/en/sender-constrained-access-tokens-mtls-vs-dpop.html ↩︎

  11. Quarkus, “Sender-constraining access tokens with Quarkus OIDC”. https://quarkus.io/blog/sender-constraining-tokens/ ↩︎

  12. Popov, A., Nyström, M., Balfanz, D., and Langley, A., “The Token Binding Protocol Version 1.0”, IETF RFC 8471, October 2018. https://www.rfc-editor.org/rfc/rfc8471 ↩︎

  13. Authgear, “Demonstrating Proof-of-Possession (DPoP): A Complete Guide for Modern OAuth Security”. https://www.authgear.com/post/demonstrating-proof-of-possession-dpop/ ↩︎

  14. Auth0, “Protect Your Access Tokens with DPoP”. https://auth0.com/blog/protect-your-access-tokens-with-dpop/ ↩︎

  15. IBM, “ISVA use of RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP)”. https://community.ibm.com/community/user/discussion/isva-use-of-rfc-9449-oauth-20-demonstrating-proof-of-possession-dpop ↩︎

  16. Tyk, “Demonstrating Proof of Possession (DPoP): OAuth2 security for FAPI 2.0 and open banking”. https://tyk.io/blog/demonstrating-proof-of-possession-dpop-oauth2-security-for-fapi-2-0-and-open-banking/ ↩︎

  17. Raidiam Developers, “OpenID Federation in the Education Sector: Governance and Trust”. https://www.raidiam.com/developers/blog/openid-federation-in-the-education-sector-governance-and-trust ↩︎

  18. Solid Authentication Panel, “Technical Objections and Concerns with Proposed DPoP + VC Scheme”, GitHub issue #47. https://github.com/solid/authentication-panel/issues/47 ↩︎