Sender-Constrained Tokens: DPoP and mTLS

This section documents OAuth 2.0 (RFC 6749) as amended by the OAuth 2.0 Security Best Current Practice (RFC 9700 / BCP 240), the OAuth 1.0 Protocol (RFC 5849) for historical context, and OpenID Connect Core 1.0, as published at the IETF Datatracker and the OpenID Foundation specifications — and, on the Spring pages, Spring Boot 4.1.x and Spring Security 7.1.x as published at the Spring Security reference documentation — which are the references these pages are written and verified against.

OAuth 2.1 is still an Internet-Draft (draft-ietf-oauth-v2-1-16, 3 September 2026) and is flagged as such everywhere it appears on these pages. It is a working-group consolidation in progress, not a published standard; nothing here should be read as saying otherwise.

This content was generated with the assistance of AI and should be verified against those official specifications before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Every token described so far on this reference — the plain access token issued at the end of the authorization code flow, the token client credentials mints for machine-to-machine calls — is, by default, a bearer token (RFC 6750): a credential that is valid simply because the holder possesses it, with no further proof required. This page covers the two IETF-standardised ways to change that: DPoP (RFC 9449), which binds a token to a key pair the client proves possession of on every request, and mutual TLS (RFC 8705), which binds a token to the client’s TLS certificate. Both rely on the same underlying primitive, RFC 7800’s cnf ("confirmation") claim, and both are covered on Security Best Practices as the recommended remediation for access-token leakage.

The bearer-token weakness, stated plainly

Whoever holds a bearer token, uses it. A resource server that receives a syntactically and cryptographically valid access token has no way to tell whether the caller is the client the authorization server issued it to, or an attacker who copied it out of a log file, a Referer header, browser history, or a compromised host. This is not a bug in any particular implementation — it is the entire design of RFC 6750, chosen deliberately in 2012 to replace OAuth 1.0’s per-request signatures with something far simpler to implement. The trade-off that decision made is spelled out on OAuth 1.0 vs. OAuth 2.0: OAuth 1.0 signed every request with a key that never travelled over the wire, so a captured request (replayed or inspected in transit) was useless without that key; OAuth 2.0 threw that property away in exchange for implementation simplicity, and leaned entirely on transport security (TLS) to protect the token in flight. It does nothing at all, however, to protect a token that leaks after a TLS-terminated hop — in a log, in an Authorization header forwarded somewhere it should not have gone, in a stolen refresh token later exchanged by someone who is not the original client. Sender-constraining is what closes that gap: a resource server can now demand cryptographic proof that the caller holding the token is also the caller it was minted for, at the cost of bringing back some of the per-request cryptography OAuth 2.0 removed.

mTLS-bound tokens (RFC 8705)

RFC 8705 — OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens — reuses a TLS client certificate the client already presents for its own connection, both to authenticate the client to the token endpoint and, separately, to bind the access token it receives to that same certificate.

Certificate-based client authentication

Two new client authentication methods sit alongside client_secret_basic / client_secret_post / private_key_jwt (covered on Client Credentials and Client Authentication):

  • tls_client_auth — the client presents an X.509 certificate during the TLS handshake with the token endpoint, issued by a CA the authorization server trusts, and the authorization server matches a subject field (tls_client_auth_subject_dn, or one of the SAN variants) against the value registered for that client. The certificate is validated as any normal PKI-issued certificate would be.

  • self_signed_tls_client_auth — the client presents a self-signed certificate; instead of chaining to a trusted CA, the authorization server compares the certificate presented during the handshake, byte for byte, against a certificate (or its public key) pre-registered for that client. This suits deployments with no existing PKI: the client generates its own key pair and certificate once, and registers the public material out of band.

POST /token HTTP/1.1
Host: as.example.com
Content-Type: application/x-www-form-urlencoded
  (TLS handshake presents the client's X.509 certificate)

grant_type=client_credentials&client_id=payments-service&scope=payments.read

Certificate-bound access tokens and the cnf/x5t#S256 claim

Whichever authentication method was used — including plain client_secret_basic, as long as the client also presented a certificate on the same TLS connection — the authorization server may bind the issued access token to that certificate, independent of how the client authenticated. It does this by embedding the certificate’s SHA-256 thumbprint in the token, under the cnf claim defined by RFC 7800, using the confirmation-method member x5t#S256:

{
  "iss": "https://as.example.com",
  "sub": "payments-service",
  "aud": "https://api.example.com",
  "scope": "payments.read",
  "exp": 1893456000,
  "cnf": {
    "x5t#S256": "bwcK0esc3ACC3DB2Y5_lESsXE8o9ltc05O89jdN-dg2"
  }
}

The resource server, presented with this token, must independently verify that the TLS client certificate presented on its own connection produces the same SHA-256 thumbprint recorded in x5t#S256. If the token is replayed over a connection authenticated with a different certificate — or with no client certificate at all — the resource server rejects it, regardless of how cryptographically valid the token itself is. Introspection responses (RFC 7662) carry the same cnf structure, so a resource server that validates by introspection rather than locally can perform the same check.

Mutual TLS suits deployments that already run a PKI — an internal service mesh, a partner integration where certificates are already provisioned and rotated, a regulated environment (FAPI, covered on PAR, JAR and Hardened Profiles, mandates it) — because the certificate infrastructure is not new work. It is a poor fit where no PKI exists yet, where clients are short-lived or highly dynamic (mobile apps, browser sessions), or where certificate provisioning and rotation cannot be automated.

DPoP (RFC 9449)

RFC 9449 — OAuth 2.0 Demonstrating Proof of Possession (DPoP) — solves the same problem without requiring a PKI or a persistent TLS client certificate: the client generates an ordinary asymmetric key pair itself (no CA involved) and proves, on every single request, that it holds the private half.

The DPoP proof JWT

On every request to the token endpoint or a protected resource, the client attaches a DPoP header carrying a freshly-signed JWT — the DPoP proof. Its JOSE header identifies it as a DPoP proof and carries the public key, not a key identifier:

{
  "typ": "dpop+jwt",
  "alg": "ES256",
  "jwk": {
    "kty": "EC",
    "crv": "P-256",
    "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs",
    "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA"
  }
}

and its claims bind the proof to exactly one request, at exactly one moment, made by exactly one client:

Claim Meaning

htm

The HTTP method of the request this proof accompanies (POST, GET, …​).

htu

The HTTP target URI, without query or fragment — must match the request URL the proof travels with.

iat

Issued-at time; the resource server rejects a proof outside a short acceptance window.

jti

A unique identifier for this proof, so the resource server can detect and reject replay of the exact same proof.

ath

Present only when the proof accompanies a resource request (not a token request): the base64url S256 hash of the associated access token, binding the proof to this specific token as well as this request.

{
  "htm": "POST",
  "htu": "https://api.example.com/orders",
  "iat": 1893455990,
  "jti": "e1j3V_bKic8-LAEB",
  "ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo"
}

The whole thing is signed by the private key matching the jwk in the header, so the resource server can verify the signature using the public key embedded right there in the proof — no separate key-distribution step is needed.

The cnf/jkt thumbprint confirmation

When the authorization server issues an access token in response to a request that included a DPoP proof, it computes the JWK SHA-256 thumbprint (RFC 7638) of the client’s public key and embeds it in the token under cnf, using the confirmation-method member jkt:

{
  "iss": "https://as.example.com",
  "sub": "user-42",
  "aud": "https://api.example.com",
  "scope": "orders.write",
  "exp": 1893456590,
  "cnf": {
    "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"
  }
}

A resource server receiving a request must: verify the DPoP proof’s signature against the jwk in its header; recompute that key’s thumbprint and confirm it equals the token’s cnf.jkt; verify htm/htu match the incoming request; verify ath matches the SHA-256 hash of the access token actually presented; and verify iat/jti fall inside an acceptable, short freshness window it has not already seen. Only when every one of those checks passes does the token count as valid for this request, on top of whatever ordinary validity checks (signature, exp, aud, scope) apply regardless of DPoP.

POST /orders HTTP/1.1
Host: api.example.com
Authorization: DPoP eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Li4ufQ...
Content-Type: application/json

{"item": "sku-991", "quantity": 2}

Note the scheme changes from Bearer to DPoP in the Authorization header — this is itself a signal to the resource server that proof-of-possession checking applies to this request.

DPoP-Nonce and replay windows

A DPoP proof’s iat and jti alone bound a replay window, not eliminate replay entirely: within that short window, a captured proof could in principle be reused. RFC 9449 lets a server (authorization server or resource server) raise the bar by issuing a DPoP-Nonce value in a response header, which the client must then echo back inside the next proof’s claims. A request whose proof lacks the currently expected nonce is rejected with a use_dpop_nonce error and a fresh nonce, forcing the client to mint a new, nonce-bound proof for every subsequent call — collapsing the effective replay window from "a short time interval" to "exactly one request." Servers that support nonces should treat each nonce as single-use and rotate it on every response, so neither a captured proof nor a captured nonce is independently reusable. Resource servers still normally cap the acceptable iat age (a small number of seconds) as a defence-in-depth measure even where nonces are in use.

Key storage in browser and mobile clients

Where the DPoP private key is generated and stored matters as much as the proof format itself:

  • Browser (SPA). Generate the key pair with the Web Crypto API (crypto.subtle.generateKey) as a non-extractable CryptoKey — the browser will sign with it, but no JavaScript, including a successfully-injected XSS payload, can ever read the raw private key material out. This is the specific mitigation Browser-Based Apps (SPAs) calls out as DPoP’s biggest win for that app type: even a full XSS compromise cannot exfiltrate the key, only ride along while the legitimate page is open.

  • Native/mobile. Generate the key pair inside the platform’s hardware-backed keystore (Android Keystore, iOS Secure Enclave) so the private key material never exists outside secure hardware and cannot be extracted even with full filesystem access to a compromised device.

  • Server-side confidential clients. An HSM or a securely-scoped in-memory key is sufficient; the threat model is different (compromise requires host-level access rather than a browser-script injection), but the key should still never be logged or persisted in plaintext alongside application data.

Sender-constraining refresh tokens

DPoP is not limited to access tokens: a refresh token can carry the same cnf/jkt binding, and RFC 9449 requires it for public clients using refresh tokens with DPoP — every refresh_token grant request must itself be accompanied by a DPoP proof signed with the same key the original token issuance was bound to, and the authorization server rejects the exchange if the proof’s key does not match. This closes exactly the refresh-token-theft scenario Security Best Practices and Access and Refresh Tokens describe rotation and reuse detection for: a stolen refresh token, sender-constrained this way, cannot be redeemed by the thief at all, rotation and reuse detection notwithstanding, because the thief does not hold the private key the token is bound to.

Interaction with introspection

A resource server that validates tokens locally (a JWT access token, at+jwt per RFC 9068) checks cnf directly against the incoming request as described above. A resource server that instead validates by calling the authorization server’s introspection endpoint (RFC 7662, covered on Opaque Tokens, Introspection and Revocation) receives the same cnf structure inside the introspection response body, and must perform the identical DPoP-proof or mTLS-certificate comparison itself — introspection confirms the token is active, but whether this caller is the one the token was bound to is always the resource server’s own responsibility, on every request, never something the authorization server can verify on the resource server’s behalf after the fact.

RFC 7800: what cnf actually means

Both mechanisms above reuse the same claim, defined once, generically, in RFC 7800 — Proof-of-Possession Key Semantics for JWTs: cnf tells whoever validates a JWT "this token is only valid when presented together with proof of possession of a specific key," and defines the confirmation-method members a validator recognises — jwk (an embedded public key, used outside OAuth for some non-token JWTs), jku/kid (a reference to a key published elsewhere), and the two used here: x5t#S256 (a certificate thumbprint, RFC 8705’s mechanism) and jkt (a JWK thumbprint, RFC 9449’s mechanism). Because the semantics are shared, a resource server library that already understands cnf for one mechanism needs comparatively little additional code to support the other.

Decision table

mTLS-bound tokens (RFC 8705) DPoP (RFC 9449)

Requires a PKI or CA

Yes for tls_client_auth; no for self_signed_tls_client_auth

No — client generates its own key pair, no certificate at all

Works in a browser (SPA)

Impractical — browsers do not expose arbitrary client-certificate management to script

Yes — Web Crypto API, non-extractable CryptoKey

Works on a public native/mobile client

Awkward — provisioning a client certificate to an app install is heavyweight

Yes — platform keystore, generated on first use

Binding granularity

Per TLS connection (all requests on that connection share the binding)

Per HTTP request (htm/htu/iat/jti), plus per token via ath

Infrastructure already in place for

Service meshes, partner/B2B integrations, regulated environments already requiring mTLS (FAPI)

General-purpose APIs, browser and mobile clients, anywhere a new PKI is unwelcome

Replay resistance

As strong as the TLS session; a hijacked connection is a hijacked binding

Bounded window by default (iat); collapsible to single-use with DPoP-Nonce

The two are not mutually exclusive — a deployment that already terminates mTLS at the edge for machine-to- machine clients can still offer DPoP for its browser and mobile clients, and a resource server can support both confirmation methods simultaneously by branching on which cnf member a given token carries.

sequenceDiagram participant A as Attacker participant RS as Resource server participant C as Legitimate client Note over A,RS: Scenario 1 -- plain bearer token leaked A->>RS: GET /orders (Authorization: Bearer stolen_token) RS-->>A: 200 OK (no way to tell attacker apart from the client) Note over A,RS: Scenario 2 -- DPoP-bound token leaked (key not leaked) A->>RS: GET /orders (Authorization: DPoP stolen_token, DPoP: forged_proof) RS-->>A: 401 invalid_dpop_proof (forged_proof not signed by the key in cnf.jkt) C->>RS: GET /orders (Authorization: DPoP legit_token, DPoP: proof signed by client's private key) RS-->>C: 200 OK (jkt thumbprint matches, htm/htu/ath all check out)

Closing observation: OAuth 1.0’s signing, scoped to where it pays off

Read together, DPoP and mTLS are OAuth 1.0’s per-request request signing coming back — not as a mandatory, universal requirement the way RFC 5849 imposed it on every single call, but as an opt-in layer applied only where the extra cryptographic work actually buys something: a public client with no secret to protect, a regulated profile that demands it, an API sensitive enough that bearer-token leakage is an unacceptable risk. OAuth 1.0 vs. OAuth 2.0 covers the original trade-off in full: OAuth 2.0 dropped signatures for simplicity, and it took over a decade of production incidents to motivate bringing a narrower version of that property back in a form that does not require every client to implement HMAC-SHA1 canonicalisation by hand.

Spring Security 7 supports DPoP natively on both the resource-server and authorization-server sides, so adopting it in a Spring deployment is a configuration change rather than a hand-rolled proof-validation pipeline — see the DPoP recipe on Spring Boot Flow Recipes for the concrete application.yml and Java configuration.