Certificate-Bound Tokens
The transport-layer equivalent of DPoP: bind the token to the TLS client certificate that requested it.
RFC 8705 binds an OAuth access token to the client certificate presented during the mTLS handshake. Same goal as DPoP — a stolen token is useless without the key — achieved one layer down.
How the binding works
- The client obtains a token over an mTLS connection to the token endpoint.
- The authorization server records the SHA-256 thumbprint of the client certificate in the token's
cnfclaim asx5t#S256. - At the resource server, the request also arrives over mTLS. The server compares the thumbprint of the presenting certificate against the one in the token.
- Mismatch, or no certificate at all, means rejection regardless of how valid the token is.
Two flavours
- PKI-based — the certificate chains to a CA the authorization server trusts, and the client is identified by its subject DN.
- Self-signed — the client registers its own certificate; the server just remembers the thumbprint. Much less infrastructure, and adequate when the registration channel is trusted.
DPoP or mTLS-bound?
| DPoP | mTLS-bound | |
|---|---|---|
| Layer | application | transport |
| Per-request cost | sign a JWT | none after handshake |
| Survives TLS termination at a proxy | yes | only with header forwarding |
| Browser-friendly | yes | no — the cert prompt is unusable |
| Infrastructure | none | CA, distribution, rotation |
| Typical use | SPAs, mobile | server-to-server, regulated |
The proxy problem
mTLS almost always terminates at a load balancer, so your application never sees the certificate — it sees a header the proxy injected. That is workable, and it has exactly one rule: the proxy must strip any inbound copy of that header before setting it. Forget that line and any client can name any certificate thumbprint it likes, which turns the entire mechanism into decoration.
Where you will meet it
Open banking (FAPI mandates sender-constrained tokens), payment networks, and healthcare integrations. FAPI 2.0 accepts either mTLS-bound tokens or DPoP — the requirement is proof-of-possession, not a specific mechanism.
Example
# 1. Get a token over mTLS — the AS records the cert thumbprint
curl -X POST https://auth.dfg.com/oauth/token \
--cert client.pem --key client-key.pem \
-d grant_type=client_credentials -d scope=payments:write
# The issued token carries the binding:
# { "sub": "payments-service",
# "cnf": { "x5t#S256": "bwcK0esc3ACC3DB2Y5_lESsXE8o9ltc05O89jdN-dg2" } }
# 2. Use it — over mTLS, with the SAME certificate
curl https://dfg.com/api/payments \
--cert client.pem --key client-key.pem \
-H "Authorization: Bearer $TOKEN"
# → 200
# 3. The same token from a different certificate
curl https://dfg.com/api/payments \
--cert other.pem --key other-key.pem \
-H "Authorization: Bearer $TOKEN"
# → 401 invalid_token (thumbprint mismatch)
# 4. Or with no certificate at all
curl https://dfg.com/api/payments -H "Authorization: Bearer $TOKEN"
# → 401When to use it
- An open-banking API satisfies its proof-of-possession requirement with certificate-bound tokens, since every partner already presents a client certificate.
- A payments service leaks a token in an internal trace, and the token is unusable because the attacker has no matching client key.
- A team chooses DPoP over mTLS binding for its mobile app after finding client certificates unmanageable on consumer devices.
More examples
Verifying the binding when TLS terminates at the app
Rejecting an unbound token on a bound route is deliberate: accepting both shapes on the same endpoint gives an attacker a downgrade path back to plain bearer.
import { createHash } from 'crypto';
// The thumbprint format RFC 8705 uses: base64url(SHA-256(DER bytes of the cert))
function certThumbprint(der) {
return createHash('sha256').update(der).digest('base64url');
}
export function mtlsBoundAuth(req, res, next) {
const claims = verifyAccessToken(readBearer(req)); // sig, iss, aud, exp
const bound = claims.cnf?.['x5t#S256'];
if (!bound) {
// A token with no binding must not be silently accepted on a bound route.
return res.status(401).json({ error: 'invalid_token', reason: 'not_bound' });
}
// Node gives us the peer certificate when the server requested one.
const peer = req.socket.getPeerCertificate?.();
if (!req.socket.authorized || !peer?.raw) {
return res.status(401).json({ error: 'invalid_token', reason: 'no_client_certificate' });
}
if (certThumbprint(peer.raw) !== bound) {
// The token is genuine — it just is not this client's token.
return res.status(401).json({ error: 'invalid_token', reason: 'certificate_mismatch' });
}
req.client = { id: claims.sub, scopes: String(claims.scope ?? '').split(' ') };
next();
}The proxy configuration, and the line that makes it safe
Every proxy has a sanitize-then-set mode. Choosing plain SET without sanitizing is the misconfiguration that turns certificate binding into a header anyone can forge.
# nginx terminates mTLS and forwards the certificate to the app.
server {
listen 443 ssl;
server_name dfg.com;
ssl_certificate /etc/ssl/server.pem;
ssl_certificate_key /etc/ssl/server-key.pem;
ssl_client_certificate /etc/ssl/ca.pem;
ssl_verify_client on; # refuse connections without a valid cert
ssl_verify_depth 2;
location /api/ {
# ⚠️ THE CRITICAL LINE: blank any inbound copy first.
# Without it a client simply sends its own X-Client-Cert header and
# claims whatever thumbprint matches the token it stole.
proxy_set_header X-Client-Cert "";
proxy_set_header X-Client-Verify "";
# Now set the VERIFIED values.
proxy_set_header X-Client-Verify $ssl_client_verify; # SUCCESS | FAILED | NONE
proxy_set_header X-Client-Cert $ssl_client_escaped_cert;
proxy_pass http://app:3000;
}
}
# Envoy does the same with forward_client_cert_details:
# forward_client_cert_details: SANITIZE_SET ← sanitize THEN set
# set_current_client_cert_details: { cert: true, dns: true }
# The 'SANITIZE' half is not optional.Reading the forwarded certificate in the application
The app must be genuinely unreachable except through the proxy — a network path that bypasses it turns the forwarded header into an unauthenticated claim.
<?php
// Behind a terminating proxy the app verifies against a forwarded header —
// which is only trustworthy because the proxy blanked any inbound copy AND the
// app is unreachable except through that proxy.
function boundClientThumbprint(Request $request): ?string
{
if ($request->header('X-Client-Verify') !== 'SUCCESS') {
return null; // handshake did not verify
}
$escaped = $request->header('X-Client-Cert');
if (! $escaped) {
return null;
}
$pem = urldecode($escaped); // nginx escapes the PEM
$der = base64_decode(preg_replace(
'/-----(BEGIN|END) CERTIFICATE-----|\s+/', '', $pem
));
return rtrim(strtr(base64_encode(hash('sha256', $der, true)), '+/', '-_'), '=');
}
Route::middleware('auth.mtls_bound')->group(function () {
Route::post('/api/payments', PaymentController::class);
});
// app/Http/Middleware/EnsureTokenIsCertificateBound.php
public function handle(Request $request, Closure $next)
{
$claims = $this->verifyAccessToken($request->bearerToken());
$bound = $claims['cnf']['x5t#S256'] ?? null;
$actual = boundClientThumbprint($request);
// hash_equals: constant time, and null-safe via the ?? ''
if (! $bound || ! $actual || ! hash_equals($bound, $actual)) {
return response()->json(['error' => 'invalid_token'], 401);
}
return $next($request);
}
Discussion