vxcontrol/pentagi · error

nonce mismatch in Google ID Token

Error message

nonce mismatch in Google ID Token

What it means

After cryptographic verification succeeds, the resolver requires that the id_token's nonce claim exactly equals the nonce sent in the authorization request (backend/pkg/server/oauth/google.go:37). A mismatch means the token cannot be bound to this specific login session, so it is rejected to prevent token injection/replay.

Source

Thrown at backend/pkg/server/oauth/google.go:37

	return func(ctx context.Context, nonce string, token *oauth2.Token) (string, bool, error) {
		provider, err := oidc.NewProvider(ctx, "https://accounts.google.com")
		if err != nil {
			return "", false, fmt.Errorf("could not create Google OpenID client: %w", err)
		}

		oidToken, ok := token.Extra("id_token").(string)
		if !ok {
			return "", false, fmt.Errorf("id_token is not present in the token")
		}

		verifier := provider.Verifier(&oidc.Config{ClientID: clientID})
		idToken, err := verifier.Verify(ctx, oidToken)
		if err != nil {
			return "", false, fmt.Errorf("could not verify Google ID Token: %w", err)
		}

		if idToken.Nonce != nonce {
			return "", false, fmt.Errorf("nonce mismatch in Google ID Token")
		}

		if err = idToken.VerifyAccessToken(token.AccessToken); err != nil {
			return "", false, fmt.Errorf("failed to verify Google Access Token: %w", err)
		}

		claims := googleTokenClaims{}
		if err := idToken.Claims(&claims); err != nil {
			return "", false, fmt.Errorf("failed to parse Google ID Token claims: %w", err)
		}

		if claims.Nonce != nonce {
			return "", false, fmt.Errorf("nonce mismatch in Google ID Token claims")
		}

		if claims.Email == "" {
			return "", false, fmt.Errorf("email is empty in Google ID Token claims")
		}

View on GitHub (pinned to ea665308ba)

Solutions

  1. Restart the OAuth flow: generate a fresh nonce, store it server-side (or in a short-lived cookie), and include it in the authorize request.
  2. Ensure exactly one in-flight Google login per browser session — cancel the other tab's flow.
  3. Store the nonce per OAuth state value so parallel logins don't overwrite each other.
  4. Treat repeated mismatches as potential token replay and investigate, do not bypass the check.
Defensive patterns

Strategy: validation

Validate before calling

// always send a fresh nonce in the authorize request and keep it per-state
nonce := rand.Text()
state := rand.Text()
store.Set("oauth_state:"+state, nonce, 10*time.Minute)
url := conf.AuthCodeURL(state, oidc.Nonce(nonce))

Try / catch

email, verified, err := googleEmailResolver(ctx, nonce, token)
if err != nil {
    if strings.Contains(err.Error(), "nonce mismatch") {
        // restart the flow with a fresh nonce; do not retry with the same token
        return "", false, fmt.Errorf("session mismatch: restart the Google sign-in from the beginning")
    }
    return "", false, err
}

Prevention

When it happens

Trigger: idToken.Nonce != nonce: the callback is processed with a nonce value that differs from the one embedded in the ID token — e.g. the stored nonce was overwritten by a second concurrent login, the token is from an earlier session, or no nonce was actually included in the authorize request.

Common situations: Two browser tabs starting OAuth simultaneously and clobbering the stored nonce (cookie/session); replaying an old callback URL; the application regenerating the nonce between the authorize redirect and the callback; multi-instance deployments without shared nonce storage.

Related errors


AI-assisted analysis of vxcontrol/pentagi@ea665308ba (2026-09-01). Data as JSON: /api/errors/70a39d42b9737c61. Report an issue: GitHub.