Skip to content

google-auth: add support for OAuth demonstrating proof-of-possesion (DPoP) #18800

Open

Description

@jay0lee

Determine this is the right repository

  • I determined this is the correct repository in which to report this feature request.

Summary of the feature request

Google has recently launched server-side support for DPoP. The tl;dr for DPoP is that it allows hardware-backing of the OAuth refresh token, requiring a client-known ECC key during access token refreshes. This means that an attacker who steals the refresh token cannot gain access tokens.

Let's add optional support for DPoP to google-auth and google-auth-oauthlib. PR Inbound.

Use case

OAuth refresh tokens are static strings. If they are cloned or stolen by an attacker the attacker can generate their own access tokens.

Adding support for DPoP means refresh token theft alone is not sufficient, the private key must also be known and since the private key used by DPoP can be stored in a [TPM / Secure Enclave / HSM / Yubikey] digital theft can be prevented.

Additional context

https://developers.google.com/identity/protocols/oauth2/resources/dpop-adoption

Activity

  1. added
    type: feature request‘Nice-to-have’ improvement, new feature or different behavior or design.
    triage meI really want to be triaged.
    on Oct 21, 2026
  2. jay0lee commented on Oct 22, 2026

    @jay0lee
    ContributorAuthor

    I am working on an implementation now. I am strongly leaning towards the DPoP implementation being a subclass of Credentials. My reasoning:

    RFC 9449 DPoP adds requirements to the OAuth dance that cut across the token refresh pipeline in ways the existing Credentials class wasn't designed to accommodate. Every access token refresh must attach a signed proof JWT to the request, inspect the response headers for a server-provided DPoP-Nonce (which _token_endpoint_request_no_throw() discarded entirely), and handle a use_dpop_nonce challenge by retrying exactly once with the new nonce. This retry logic doesn't exist in the standard refresh path. Implementing this in-place would mean adding DPoP conditionals throughout _perform_refresh_token, to_json, and from_authorized_user_info, polluting every core Credentials method with branches that non-DPoP callers would never exercise.

    The subclass isolates all of that to a single new file with only two minimal, backward-compatible touches to existing code: extracting a _call_refresh_grant() hook from _perform_refresh_token() (zero behavioral change—it just calls reauth.refresh_grant() by default), and adding an optional return_response_headers parameter to _token_endpoint_request_no_throw() so we can capture nonce headers. The subclass would override the hook for proof injection and nonce handling without duplicating the parent Credentials validation, expiry calculation, or RAPT/reauth orchestration. Non-DPoP users see exactly zero new code paths.

    Callers wishing to use DPoP will need to supply a long-lived signing key which, per Google's best practice should be stored away from our existing JSON credentials (refresh token, client secret, etc). If they're stored in the same place an attacker still only needs to grab one file to compromise the refresh token. My PR will include a SoftwareDPoPSigner class which can sign using a private key PEM written to disk but I expect secure production solutions to implement their own DPoPSigner classes that utilize hardware-backed key stores.

  3. jay0lee commented on Oct 22, 2026

    @jay0lee
    ContributorAuthor

    Here's a PoC script that demonstrate's this PR's use against Google servers. It uses minimal email/openid scopes and just needs a desktop client_secrets.json. We'll use OpenSSL to generate two keys, the real key which we'll use in our DPoP authorization flow on first run and an attacker key which is unknown to Google's servers.

    """DPoP Proof-of-Concept: token refresh bound to a cryptographic key.
    Setup:
        openssl ecparam -name prime256v1 -genkey -noout -out real_key.pem
        openssl ecparam -name prime256v1 -genkey -noout -out attacker_key.pem
    First run (authorize and bind to real key):
        python dpop_poc_demo.py real_key.pem
    Second run (refresh with the same key — succeeds):
        python dpop_poc_demo.py real_key.pem
    Third run (refresh with a different key — fails):
        python dpop_poc_demo.py attacker_key.pem
    """
    import os
    import sys
    from google.auth.dpop import SoftwareDPoPSigner
    from google.auth.transport.requests import Request
    from google.oauth2.dpop_credentials import DPoPCredentials
    from google_auth_oauthlib.flow import InstalledAppFlow
    SCOPES = ["openid", "https://www.googleapis.com/auth/userinfo.email"]
    CLIENT_SECRETS = "client_secrets.json"
    CREDS_FILE = "dpop_creds.json"
    if len(sys.argv) != 2:
        print("Usage: {} <key.pem>".format(sys.argv[0]))
        sys.exit(1)
    signer = SoftwareDPoPSigner.from_pem(open(sys.argv[1], "rb").read())
    print("Key:        {}".format(sys.argv[1]))
    print("Thumbprint: {}".format(signer.thumbprint))
    if not os.path.exists(CREDS_FILE):
        # No saved credentials — run the OAuth flow.
        print("\nNo saved credentials. Starting OAuth flow...")
        flow = InstalledAppFlow.from_client_secrets_file(
            CLIENT_SECRETS, scopes=SCOPES, dpop_signer=signer
        )
        flow.run_local_server(port=8080)
        creds = flow.credentials
        with open(CREDS_FILE, "w") as f:
            f.write(creds.to_json())
        print("Token:   {}...".format(creds.token[:30]))
        print("Saved to {}".format(CREDS_FILE))
    else:
        # Saved credentials exist — load and refresh.
        print("\nLoading saved credentials...")
        import json
        with open(CREDS_FILE) as f:
            info = json.load(f)
        saved_thumbprint = info.get("dpop_thumbprint")
        if saved_thumbprint and saved_thumbprint != signer.thumbprint:
            print("Note: thumbprint mismatch (saved={}, yours={})".format(
                saved_thumbprint[:16] + "...", signer.thumbprint[:16] + "..."
            ))
            print("Sending to Google anyway to demonstrate server rejection...\n")
  4. added
    priority: p2Moderately-important priority. Fix may not be included in next release.
    and removed
    triage meI really want to be triaged.
    on Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    authpriority: p2Moderately-important priority. Fix may not be included in next release.type: feature request‘Nice-to-have’ improvement, new feature or different behavior or design.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions