DPoP
This page helps your app get access tokens bound to a key with DPoP, so a token copied away from your app can’t be used.
An ordinary access token is a bearer token: whoever holds it can use it. With DPoP (Demonstrating Proof of Possession, RFC 9449), your app keeps a private key and signs a short JWT, a proof, for each request it makes. The auth server binds the access token to that key, so a thief who copies the token, out of a log or a browser, can’t make the proof that goes with it.
DPoP is opt-in per request: send a proof with a token request, on any grant, and the access token comes back bound. Without one, nothing changes, unless an administrator has set your client to require DPoP for token requests. Most DPoP client libraries do everything below for you.
Get a bound access token
Section titled “Get a bound access token”-
Make a key pair, such as an ES256 (P-256) key, and keep it for as long as your app keeps its tokens. The private key never leaves your app.
-
Make a proof for the token request. Its header names the algorithm and carries your public key as a JWK:
{"typ": "dpop+jwt","alg": "ES256","jwk": {"kty": "EC","crv": "P-256","x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs","y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA"}}Its claims name the request it’s for:
{"jti": "e1j3V_bKic8-LAEB","htm": "POST","htu": "https://auth.example.com/auth/token","iat": 1760000000}Sign it with the private key, and send it in a
DPoPheader with the token request. -
The first answer asks for a nonce, a value from the auth server that your proof must carry:
HTTP/1.1 400 Bad RequestContent-Type: application/jsonCache-Control: no-storeDPoP-Nonce: AAAAAGjx...{"error": "use_dpop_nonce","error_description": "Authorization server requires nonce in DPoP proof."} -
Make a new proof with a new
jti, theDPoP-Noncevalue as itsnonceclaim, and send the token request again. The answer hastoken_typeDPoP:HTTP/1.1 200 OKContent-Type: application/jsonCache-Control: no-storeDPoP-Nonce: AAAAAGjx...{"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ii4uLiJ9...","token_type": "DPoP","expires_in": 300,...}
The nonce
Section titled “The nonce”The nonce is how the auth server knows a proof was made just now, not stored up in advance. Every answer to a token request with a DPoP header carries a fresh one in DPoP-Nonce, success or error. Use the latest you’ve seen in your next token request.
A nonce is good for 10 minutes. After that, the auth server answers use_dpop_nonce again, with a new nonce. Your app must always handle use_dpop_nonce by retrying once with the new nonce, as DPoP libraries do.
At the token endpoint, the nonce is what makes a proof fresh. Your proof still needs an iat, but your app’s clock doesn’t have to be right.
Refresh tokens bound to your key
Section titled “Refresh tokens bound to your key”A public client’s refresh token is bound to its key too. Once your app has a refresh token from a request with a proof, every refresh of it needs a proof by that same key, and so does every refresh token after it. A refresh without a proof, or with a proof by another key, is refused with invalid_grant, and spends nothing: the refresh token still works with the right key.
Keep the key as long as you keep the refresh token. An app that loses its key, or makes a new one, has to sign the user in again.
A public client’s refresh token issued without a proof is bound by its first refresh with one, and stays bound from then on.
A confidential client’s refresh token isn’t bound: its client authentication already ties the token to it, so it can change keys at any refresh. Each of its access tokens is still bound to the key of the request that got it.
Bind the code to your key
Section titled “Bind the code to your key”Send dpop_jkt with the authorization request, and the code is bound to your key before the user even signs in. Its value is your key’s thumbprint, the one a bound token carries in cnf.jkt:
GET /auth/authorize?client_id=my-app&response_type=code&...&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&dpop_jkt=NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs HTTP/1.1Host: auth.example.comA bound code is redeemed only with a proof by that key. Redeeming it without a proof, or with a proof by another key, is refused with invalid_grant, and spends nothing: the code can still be redeemed with the right key, within its 60 seconds.
It’s optional, even when your client requires DPoP, and only response_type=code takes it. Send it beside PKCE, not in place of it.
Call userinfo and the API with a bound token
Section titled “Call userinfo and the API with a bound token”Goiabada’s own resources, UserInfo and the Admin and Account APIs, take a bound token with the DPoP scheme, beside a new proof for each call:
GET /userinfo HTTP/1.1Host: auth.example.comAuthorization: DPoP eyJhbGciOiJSUzI1NiIsImtpZCI6Ii4uLiJ9...DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7...The proof’s htm and htu name this request, GET and https://auth.example.com/userinfo, and its ath claim is the hash of the access token. It carries no nonce: these endpoints don’t hand them out.
Your app’s clock must be within a minute of the auth server’s. Without a nonce, the proof’s iat is what makes it fresh, and a proof made more than 60 seconds before or after the auth server’s time is refused.
A bound token sent as Bearer, in the header or a POST body, is refused, and so is a token bound to no key sent with DPoP.
Keep a DPoP client’s access tokens short-lived. Userinfo and the API don’t hand out nonces, so a proof made in advance for a token, by malware running in your app for example, can work there until the token expires. UserInfo lists each refusal and its WWW-Authenticate challenges.
What a proof must say
Section titled “What a proof must say”| Part | Rule |
|---|---|
typ |
dpop+jwt |
alg |
One of ES256, ES384, ES512, PS256, PS384, PS512, RS256, RS384, RS512, Ed25519 and EdDSA, as discovery’s dpop_signing_alg_values_supported lists them. none and symmetric algorithms are never accepted |
jwk |
Your public key, of the type the algorithm takes: an EC key on the curve it names, an RSA key of 2048 to 8192 bits, or an Ed25519 key for Ed25519 and EdDSA. A key with any private part is refused |
jti |
A new identifier for each proof, a retry included. A proof is accepted once: sent again, it’s refused. At userinfo and the API, a proof is spent even when the call is refused |
htm |
The request’s method, exactly: POST at the token endpoint |
ath |
At userinfo and the API, the hash of the access token sent beside it: its SHA-256, base64url-encoded without padding |
htu |
The URL of the request: the auth server’s base URL, GOIABADA_AUTHSERVER_BASEURL, followed by the endpoint’s path, such as https://auth.example.com/auth/token. The query and fragment are ignored. A proof naming the internal base URL is refused |
iat |
When your app made the proof, in seconds since 1970. At userinfo and the API, within 60 seconds of the auth server’s clock |
nonce |
At the token endpoint, the latest DPoP-Nonce. Userinfo and the API don’t read it |
Send one DPoP header, of at most 8 KiB. A request whose proof fails any of these checks but the nonce is refused with invalid_dpop_proof: 400 Bad Request at the token endpoint, 401 Unauthorized at userinfo and the API. At userinfo and the API, a proof that passes but is signed by another key than the token’s is refused with invalid_token. To find which check failed, see Logs.
What a bound token carries
Section titled “What a bound token carries”A bound access token’s cnf.jkt claim is the RFC 7638 thumbprint of your key: its required members, hashed with SHA-256 and base64url-encoded without padding. That claim is what says a token is bound. See The access token.