This guide details how to implement DPoP (Demonstrating Proof-of-Possession) in your OAuth 2.0 integrations with Google's OAuth Platform. DPoP (defined in RFC 9449) secures your applications against token theft and replay attacks by cryptographically binding tokens to a client-generated asymmetric key pair.
Authorization Code flow changes
Adding DPoP to an existing OAuth 2.0 Authorization Code flow requires generation and storage of a keypair, construction of a DPoP proof JWT, and including the proof as an HTTP header when the authorization code is exchanged for a refresh token as shown in steps 5 and 6 of Figure 1.
Authorization code request
The authorization request is created normally. For example:
$ curl -G "https://accounts.google.com/o/oauth2/v2/auth" \
--data-urlencode "client_id=YOUR_CLIENT_ID.apps.googleusercontent.com" \
--data-urlencode "redirect_uri=http://127.0.0.1:8080" \
--data-urlencode "response_type=code" \
--data-urlencode "scope=calendar.readonly" \
--data-urlencode "state=AI1Bvapj7E5SDmtW4gohcA" \
--data-urlencode "code_challenge=PO4pPROl-31Wy9fVZ7uTW9Ga6CrjrSKsf4AAtx_JNM8" \
--data-urlencode "code_challenge_method=S256" \
--data-urlencode "nonce=PrMfmSNAvJFPQ7GnlEKUaw" \
--data-urlencode "access_type=offline" \
--data-urlencode "prompt=consent"
The authorization code returned as a redirect URI parameter is used in the construction of a DPoP proof. The refresh token is bound to the proof included as an HTTP header in all further requests to the token endpoint.
Pure, secretless client-side SPAs cannot use DPoP directly due to the
client_secret requirement and CORS limitations on the DPoP-Nonce header. To
secure SPAs, route traffic through a Backend-for-Frontend (BFF) that acts as a
Confidential Client, enables access_type=offline, and utilizes DPoP to bind
the refresh token server-side.
Construct the DPoP proof
A proof contains a JOSE header and a payload.
To construct the header, generate an EC P-256 (ES256) keypair and include
the public key coordinates (x and y) in the jwk parameter. An RSA
keypair (RS256) is also supported, though ES256 is recommended due to
smaller signature size and lower computational cost.
This is an example JOSE header:
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "VC91y9ZYdfSWaDv8JaI6gx5ifOw2rn3YdqkAB51Uu6E",
"y": "ikPjOtea4k7fWPVrRYwaA4Ww6iVY3pOOICotHwwGV3o"
}
}
To construct the proof payload, four claims are required (htm, htu, iat,
and jti), plus an optional nonce claim.
Two claims: htm: POST and htu: https://oauth2.googleapis.com/token are fixed
values and don't change when making a request to Google's token endpoint.
The other two claims: iat and jti must be generated for every request. The
value of iat is the issued-at timestamp and changes per request. The value of
the JWT ID (jti) claim depends on the type of exchange. When an authorization
code is exchanged for access and refresh tokens the value of jti is the
Base-64 and Url-encoded SHA256 hash of the authorization code, such as jti =
BASE64URL(SHA-256(authorization_code)). A nonce claim is optional on the
initial authorization code exchange and only needed if your client already
holds a valid DPoP-Nonce from a prior request.
This is an example payload body:
{
"jti": "o29CN8LIY0l_N8iy5-ilon1guad9NFQHFOdXTzrBNck",
"htm": "POST",
"htu": "https://oauth2.googleapis.com/token",
"iat": 1784822025
}
The JOSE header and payload body are encoded and signed as a JWT (RFC 7519) for
direct use in the DPoP HTTP header in the token request. For ES256, the JWS
signature must use raw IEEE P1363 format (R || S, 64 bytes for P-256):
$ curl -X POST https://oauth2.googleapis.com/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVDIiwiY3J2IjoiUC0yNTYiLCJ4IjoiVkM5MXk5WllkZlNXYUR2OEphSTZneDVpZk93MnJuM1lkcWtBQjUxVXU2RSIsInkiOiJpa1BqT3RlYTRrN2ZXUFZyUll3YUE0V3c2aVZZM3BPT0lDb3RId3dHVjNvIn19.eyJqdGkiOiJvMjlDTjhMSVkwbF9OOGl5NS1pbG9uMWd1YWQ5TkZRSEZPZFhUenJCTmNrIiwiaHRtIjoiUE9TVCIsImh0dSI6Imh0dHBzOi8vb2F1dGgyLmdvb2dsZWFwaXMuY29tL3Rva2VuIiwiaWF0IjoxNzg0ODIyMDI1fQ.OSdQCmqTng_uZmGK5UXf8hcEMtoOu7ucmYtl5mx4901RXnj6fJRJQmIeTqfhprRBTG_RSJv2fPcWDqvQbDW7YA" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=4/0AXEQxIDNpLD-qpSIvjHb2Hl10uS_2sk2GBRpO8UJQ78YZF3hZ9LB9kTA1xYLD4xisi4C5w" \
--data-urlencode "redirect_uri=http://127.0.0.1:8080" \
--data-urlencode "client_id=YOUR_CLIENT_ID.apps.googleusercontent.com" \
--data-urlencode "client_secret=YOUR_CLIENT_SECRET" \
--data-urlencode "code_verifier=q8ZztyVv7HH8E2M-SEL8WaB-7CPs68rejN5UZ9OdYgo"
A DPoP bound refresh token is returned along with a DPoP-Nonce HTTP header,
for example:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
DPoP-Nonce: AN3XwJjZsjnb0ZuWkRlek8QU7wY-Zhf-5IP6tO0tORz0KgtDT1Bo8FX-w4nz3r5lnepI
{
"access_token": "ya29.a0ARGnu0aebRL97B91dmvm14gTug5wpItFf9MVWq12Hja6yv09A_qxa4T73_z2gFbf32qR4RXispQ7vnOzv6gn0APLQrF51LVa6AOqCVPH2Tupocv8y0JHu4ByEbvgXEEhiHEU8Xa9_w3i-PKBPsKWiLi210RCZdqJjLXkcRrGnoPPjbGPzOPtm6KCJjPrNHG16caOWecaCgYKASESARASFQHGX2MiBn7ihbbk_n-buCbOfl2TDA0206",
"expires_in": 3599,
"refresh_token": "1//06dUPZ9FIBQm3CgYIARAAGAYSNwF-L9IrJwuIEKUA_zbBPU-xoCDGM0QrDu7-jv7cMQZ0kARPUK9WhwfFFfbOVEgXDQKmFh4w9GM",
"scope": "https://www.googleapis.com/auth/calendar.readonly",
"token_type": "Bearer"
}
Always cache and send the most recent DPoP-Nonce returned by Google's
authorization server in subsequent token requests. Nonces expire periodically,
and if a proof is missing a required nonce claim or contains an invalid or
expired nonce, the server returns an HTTP 400 (use_dpop_nonce) response
with a fresh DPoP-Nonce header for use in retries.
Token refresh flow changes
Updating an existing OAuth 2.0 Token Refresh flow requires the generation and sending of a DPoP proof as an HTTP header when a refresh token is exchanged for new tokens as shown in steps 2-5 of Figure 2.
Building the DPoP proof
The method to construct a proof for token refresh differs from the authorization
code scenario. The JOSE header is constructed in the same manner as described
previously when building an authorization code request. The proof body is
constructed similarly but includes a nonce claim and jti contains a unique
random string.
To construct the payload body the previously returned DPoP-Nonce HTTP header
value must be included in the nonce claim and the issued-at timestamp (iat)
updated for every request. The JWT ID (jti) is a unique random string
generated per request, using the built-in WebCrypto API
crypto.getRandomValues(new Uint8Array(24)) and Base64URL-encoding the string.
This is an example payload body containing jti, nonce, and iat:
{
"jti": "o29CN8ZIY0l_K8iy5-ilon1guad9NF6HFOdXTzrBNck",
"htm": "POST",
"htu": "https://oauth2.googleapis.com/token",
"nonce": "AN3XwJjZsjnb0ZuWkRlek8QU7wY-Zhf-5IP6tO0tORz0KgtDT1Bo8FX-w4nz3r5lnepI",
"iat": 1784822025
}
The JOSE header and payload body are encoded as a JWT (RFC7519) for direct use in the DPoP HTTP header in the token request.
The proof is added as a DPoP header to the token refresh request:
$ curl -X POST https://oauth2.googleapis.com/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVDIiwiY3J2IjoiUC0yNTYiLCJ4IjoiVkM5MXk5WllkZlNXYUR2OEphSTZneDVpZk93MnJuM1lkcWtBQjUxVXU2RSIsInkiOiJpa1BqT3RlYTRrN2ZXUFZyUll3YUE0V3c2aVZZM3BPT0lDb3RId3dHVjNvIn19.eyJqdGkiOiJvMjlDTjhaSVkwbF9LOGl5NS1pbG9uMWd1YWQ5TkY2SEZPZFhUenJCTmNrIiwiaHRtIjoiUE9TVCIsImh0dSI6Imh0dHBzOi8vb2F1dGgyLmdvb2dsZWFwaXMuY29tL3Rva2VuIiwibm9uY2UiOiJBTjNYd0pqWnNqbmIwWnVXa1JsZWs4UVU3d1ktWmhmLTVJUDZ0TzB0T1J6MEtndERUMUJvOEZYLXc0bnozcjVsbmVwSSIsImlhdCI6MTc4NDgyMjAyNX0.ObT0BejZz2yi8atNSStsQH-T2atr8OSOL4JD2ZJlU_VxbiqkCE7Dt9RKyUT_3kj7q1vCvVggwnWJNX3P3vO1mw" \
--data-urlencode "grant_type=refresh_token" \
--data-urlencode "refresh_token=1//06dUPZ9FIBQm3CgYIARAAGAYSNwF-L9IrJwuIEKUA_zbBPU-xoCDGM0QrDu7-jv7cMQZ0kARPUK9WhwfFFfbOVEgXDQKmFh4w9GM" \
--data-urlencode "client_id=YOUR_CLIENT_ID.apps.googleusercontent.com" \
--data-urlencode "client_secret=YOUR_CLIENT_SECRET"
When an expired or invalid nonce is used or when transitioning between
different OAuth workflows (such as moving from the initial Authorization Code
Exchange to a Token Refresh request) Google's server enforces workflow
isolation. This means the server unconditionally rejects the nonce with an HTTP
400 use_dpop_nonce challenge to establish a fresh nonce namespace for the
new workflow.
This is an example 400 response that requires a retry and a new proof to be
constructed using the DPoP-Nonce value:
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
DPoP-Nonce: AO4t07Kf85RJXmltUhiAiELLPPrJ4zOi66zWxU1uDZbhRcahFBYvT0WlcjSSXULXknSA
{
"error": "use_dpop_nonce",
"error_description": "New DPoP nonce issued due to invalid or expired challenge."
}
On success, a new nonce and short-lived access token are returned:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
DPoP-Nonce: AO4t07IXuovyCbtLEr6VVFZQ_Kb78MMOXTt6-CyZpsJeF62HZ3P_EW55XbWqYcU76Jg=
{
"access_token": "ya29.a0ARGnu0bDj9BAQYVbF5hi3vw-brBUZBZu1bnInk1hS7gueqEb6QPqUjDGb0MMj9A0QX5FRrJo3FDw-DEDtvVbRUdeCgjwsL_LVVFXz-p-MUyiFyRoufI4KC0Go9aq5cEjD_BWvOJLMSIY6_EnwnhqDgk0XxvzaaAxDnv8PXJAGev_UotcfApstqi0NCxbfi-6Kgull9QaCgYKAUQSARASFQHGX2MiZpMjRS6z4S0RjOkNxn2o1Q0206",
"expires_in": 3599,
"scope": "https://www.googleapis.com/auth/calendar.readonly",
"token_type": "Bearer"
}
Save the DPoP-Nonce value for use in the next request.
See Using OAuth 2.0 for Web Server Applications and Best Practices for additional details and recommendations.