Skip to content

impersonate: JWT exchanged with legacy grant_type=assertion, now rejected with "invalid_grant: Invalid JWT Signature" #3671

Description

@matiasbertani

Environment details

  • Programming language: Go
  • OS: Linux and macOS (also reproducible with plain curl, see below)
  • Language runtime version: go1.23
  • Package version: v0.283.0 (the relevant code is unchanged on master)

TL;DR

impersonate.CredentialsTokenSource with Subject set signs a JWT via
iamcredentials.signJwt and then exchanges it at https://oauth2.googleapis.com/token
using the pre-RFC 7523 legacy grant:

https://github.com/googleapis/google-api-go-client/blob/v0.283.0/impersonate/user.go#L144-L148

Since around 2026-07-16, that exchange fails with
{"error":"invalid_grant","error_description":"Invalid JWT Signature."} for JWTs signed
with recently-rotated system-managed keys. The exact same JWT is accepted when exchanged
with grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, which is what
google-auth-library-python uses for this same flow
(_client.py,
impersonated_credentials.py).

Google rotates system-managed keys every ~2 weeks, so every user of this code path
breaks as soon as their SA's key rotates — with no client-side change involved. We hit
this through oauth2-proxy (it uses this package for Google Groups authorization) in four
unrelated GCP projects: in each one, failures started right after that SA's key
rotation, intermittent at first, 100% within a week. Config had been untouched for
months, and we verified every other hop (STS, generateAccessToken, signJwt) works.

Steps to reproduce

Any SA you can call signJwt on works; a freshly-created SA reproduces it immediately
since its key is new.

SA=<sa>@<project>.iam.gserviceaccount.com
NOW=$(date +%s)
cat > claims.json <<EOF
{"iss":"$SA","sub":"$SA","aud":"https://oauth2.googleapis.com/token","scope":"https://www.googleapis.com/auth/admin.directory.group.member.readonly","iat":$NOW,"exp":$((NOW+3600))}
EOF
gcloud iam service-accounts sign-jwt claims.json signed.jwt --iam-account="$SA"

# what impersonate/user.go sends:
curl -s https://oauth2.googleapis.com/token \
  -d grant_type=assertion \
  --data-urlencode "assertion_type=http://oauth.net/grant_type/jwt/1.0/bearer" \
  --data-urlencode "assertion=$(cat signed.jwt)"
# -> {"error":"invalid_grant","error_description":"Invalid JWT Signature."} (HTTP 400)

# same JWT, RFC 7523 grant:
curl -s https://oauth2.googleapis.com/token \
  -d grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \
  --data-urlencode "assertion=$(cat signed.jwt)"
# -> {"access_token":"ya29...","expires_in":3599,"token_type":"Bearer"} (HTTP 200)

The only difference between the two requests is the grant format; the JWT is
byte-for-byte the same and was signed by Google minutes earlier, so this isn't a
malformed assertion or a key problem on the caller's side.

Expected behavior

The exchange should use the RFC 7523 grant like the Python library does. It's a
two-line change in exchangeToken — happy to send a PR.

Note that cloud.google.com/go/auth has the same legacy grant in its equivalent flow
(user.go),
so it needs the same fix.

Is the legacy grant_type=assertion path being turned down server-side? Its validator
seems to no longer pick up newly-rotated keys, which is what makes the error so
misleading. If there's a decommission timeline it would be good to know, since affected
users will keep trickling in as their keys rotate.

Workaround meanwhile: use a SA JSON key (golang.org/x/oauth2/jwt uses the modern
grant), or drop Subject if plain impersonation works for your case.

Metadata

Metadata

Assignees

No one assigned

    Labels

    priority: p2Moderately-important priority. Fix may not be included in next release.type: bugError or flaw in code with unintended results or allowing sub-optimal usage patterns.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions