Verifying a Google token yourself

OAuth with Google · lesson 9 of 9 · 6 min read

When a mobile or SPA client sends you an ID token, what you must check.

Open this lesson in the learning hub

Key points

  • A common architecture has the client complete the Google flow and send the resulting ID token to your backend. Your backend must then verify it properly - the client cannot be trusted to have done so.
  • Verify the signature against Google published keys, and cache them. Google rotates regularly, so a hard-coded key is an outage on a schedule.
  • Check aud equals your client id. Without it, a token minted for any other application is accepted, which is the confused-deputy attack.
  • Check iss is accounts.google.com or https://accounts.google.com - Google uses both forms, and accepting only one causes intermittent failures.
  • Check email_verified before trusting the email for anything, and still resolve the account by sub rather than by email.
  • Never accept an access token as proof of identity. It is a bearer credential for Google APIs, carries no audience binding to you, and is exactly what the token-swap attack uses.

Example

// The client sends the ID TOKEN, never the access token.
@PostMapping("/auth/google")
public ResponseEntity<SessionResponse> signIn(@RequestBody TokenRequest request) {

    GoogleIdToken idToken;
    try {
        // The verifier caches Google keys and handles rotation.
        idToken = verifier.verify(request.idToken());
    } catch (GeneralSecurityException | IOException e) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }
    if (idToken == null) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }

    GoogleIdToken.Payload payload = idToken.getPayload();

    // Signature, aud, iss and exp are checked by the verifier below.
    // These two are YOUR responsibility:
    if (!Boolean.TRUE.equals(payload.getEmailVerified())) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
    }

    // Resolve by sub - the stable id - never by email.
    Account account = accounts.findByGoogleSub(payload.getSubject())
            .orElseGet(() -> accounts.createFrom(payload));

    // Issue YOUR session or token. The Google token stops here.
    return ResponseEntity.ok(sessions.start(account));
}

@Bean
GoogleIdTokenVerifier verifier(@Value("${google.client-id}") String clientId) {
    return new GoogleIdTokenVerifier.Builder(
                new NetHttpTransport(), GsonFactory.getDefaultInstance())
            // aud MUST be your client id - this is the confused-deputy check
            .setAudience(List.of(clientId))
            // Google issues BOTH forms; accept both or you get flaky 401s
            .setIssuers(List.of("accounts.google.com", "https://accounts.google.com"))
            .build();
}

/*
 * THE ATTACK IF YOU ACCEPT AN ACCESS TOKEN INSTEAD:
 *
 *   1. attacker builds any app; a victim grants it access
 *   2. attacker now holds a valid Google ACCESS token for that victim
 *   3. attacker posts it to YOUR /auth/google
 *   4. you call Google userinfo with it, get the victim details,
 *      and sign the attacker in as the victim
 *
 * An access token has no audience binding to you. Only the ID TOKEN,
 * with aud == your client id, proves the user signed in TO YOUR APP.
 */

Verify the ID token with aud set to your client id - an access token proves nothing about which app the user signed in to.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the OAuth with Google course, and every lesson in it is listed on the OAuth with Google contents page.