Binding a token to a key
The printer's phone app keeps your photo connection on your phone. It holds an access token for the photo API and a refresh token for getting the next one. Token theft and replay followed the ways a token like that can escape, and ended with the alternative to a bearer token: a sender-constrained token, which works only for the party that can prove it holds a particular key.
Demonstrating Proof of Possession, usually shortened to DPoP and published as RFC 9449, is one standard way to build that binding. It works entirely in ordinary HTTP requests, which makes it practical for an app on a phone.
A token that needs its key
The outline is the one Token theft and replay sketched. The app creates a key pair before it asks for tokens. With its token request it sends a small signed JWT, called a DPoP proof, that contains its public key, and the authorization server records which public key the token belongs to. From then on, every request that uses the token must arrive with a fresh proof signed by the matching private key. It is the proof of possession from Secrets and key pairs, repeated on every call.
The private key never travels. It does not go to the authorization server or the photo API, and it does not appear in any token. Only the public key is sent, inside each proof, and a public key is no help to anyone trying to sign.
Giving the app a key pair
All domains, codes, and tokens in these examples are fictional. When you connect your photo account in the app, the app generates an elliptic curve key pair on the P-256 curve, which it will use with the ES256 signature algorithm. It keeps the private key in the platform's protected key storage, such as the Keychain on Apple devices or the Android Keystore, and marks it non-exportable where the platform allows. As Storing and using keys explained, the app can then ask for signatures but cannot read out the key itself, and neither can anything that copies the app's files.
The public half, written as a JSON Web Key, looks like this:
{
"kty": "EC",
"crv": "P-256",
"x": "DboN9G_lcWgZ3rxckKu35JwH2OYBLWJgxcT_-KEXK8Y",
"y": "rj69LkdPmdnAbPoBq6mpcpbJPWLEQ6Qs2SaMMyKWBSk"
}
This public key was generated for these examples, and its private half was discarded, so nobody can sign with it.
Nothing about this key is registered in advance. Unlike a key registered for private_key_jwt, which the authorization server knows before any request arrives, a DPoP key can be created by any installation at any moment. That is why DPoP suits public clients. There is no secret to hide in the app package, because each copy of the app makes its own key on its own device.
It also means DPoP is not client authentication. Anyone can generate a key pair and send a proof. A valid proof shows that the request comes from the same holder as the one the token was issued to. It does not show that the holder is the genuine printer app, and the app remains a public client.
Asking for a bound token
After you approve the connection, the app exchanges its authorization code as usual, with one addition: a DPoP header carrying a proof, shortened here for display:
POST /token HTTP/1.1
Host: auth.photos.example
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVDIiwiY3J2IjoiUC0yNTYi...
grant_type=authorization_code
&code=demo-app-code-1
&redirect_uri=https%3A%2F%2Fapp.printer.example%2Foauth%2Fcallback
&code_verifier=demo_app_verifier_created_for_one_attempt_only_2026
&client_id=photo-printer-app
The authorization server checks the proof, then binds the tokens it issues to the public key inside it:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{
"access_token": "demo-app-access-token-1",
"token_type": "DPoP",
"expires_in": 600,
"refresh_token": "demo-app-refresh-token-1",
"scope": "photos.read"
}
The token_type is the signal. DPoP tells the app that the access token is bound to its key and must be sent with a proof on every request. If the server answers Bearer instead, it has chosen not to bind the access token, and the token is an ordinary bearer token. A client that relies on the binding for its security must discard that response rather than carry on unprotected.
Because the app is a public client, the refresh token is bound to the same key. A refresh token copied from the phone is as useless without that key as the access token is.
Recording the key in the token
The photo API needs to know which key the token belongs to, without asking the app. The authorization server tells it inside the token. As in earlier lessons, demo-app-access-token-1 is a readable stand-in for an encoded JWT. Decoded, its claims are:
{
"iss": "https://auth.photos.example",
"sub": "user-2048",
"aud": "https://api.photos.example",
"client_id": "photo-printer-app",
"scope": "photos.read",
"iat": 1790845200,
"exp": 1790845800,
"jti": "demo-app-token-id-1",
"cnf": {
"jkt": "BVohHgHi0D9O5iBFRpVSFAukBjzgVbIRUjYuTsfJER8"
}
}
The cnf claim is the confirmation claim that Token theft and replay mentioned: it names the key that a presenter of this token must prove it holds. Its jkt member is the JWK thumbprint of the app's public key: a SHA-256 hash of a fixed, canonical form of the key, defined in RFC 7638. A thumbprint is short, the same size for every key, and changes completely if any part of the key changes. Creating a DPoP proof builds the proof itself and computes this thumbprint step by step.
When the access token is opaque rather than a JWT, the API learns the same value from the authorization server instead. A token introspection response for a bound token carries the same cnf member with the same thumbprint.
Put the pieces together and a stolen token looks very different. Someone who copies demo-app-access-token-1 also needs a proof signed by a key whose thumbprint is BVohHgHi0D9O5iBFRpVSFAukBjzgVbIRUjYuTsfJER8. They can make a proof with a key of their own, but its thumbprint will not match, and the photo API will reject the request. The token has become one half of a credential, and the other half stays in the phone's key storage.