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 ( 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 |
|---|---|
|
The HTTP method of the request this proof accompanies ( |
|
The HTTP target URI, without query or fragment — must match the request URL the proof travels with. |
|
Issued-at time; the resource server rejects a proof outside a short acceptance window. |
|
A unique identifier for this proof, so the resource server can detect and reject replay of the exact same proof. |
|
Present only when the proof accompanies a resource request (not a token request): the base64url |
{
"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-extractableCryptoKey— 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 |
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 |
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 ( |
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 ( |
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.
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.