There was a "Sign in with Google" feature on a booking platform I built, and functionally it worked flawlessly. The user clicked the button, the Google popup appeared, and a few seconds later they were logged in with the right name and email. No errors, no warnings. What made me stop was rereading the login endpoint and realizing the server never actually asked Google whether the token was real. It just read what was inside it.
The whole problem lived in this small piece, which took the ID token from the client and immediately trusted its contents:
// Server receives id_token from the frontend, then splits it
function login_with_google( $id_token ) {
$parts = explode( '.', $id_token );
$payload = json_decode( base64_decode( $parts[1] ), true );
// Trusted immediately
$email = $payload['email'];
$sub = $payload['sub'];
$user = get_user_by( 'email', $email );
wp_set_auth_cookie( $user->ID );
return $user;
}No error string ever showed up, because nothing was failing. That is exactly what made it frightening. I proved it by hand-crafting a fake token myself and posting it to the endpoint:
# header and payload are invented, signature is garbage
HEADER=$(printf '{"alg":"RS256","typ":"JWT"}' | base64)
PAYLOAD=$(printf '{"email":"admin@old-site.com","sub":"123"}' | base64)
FAKE="$HEADER.$PAYLOAD.not-a-real-signature"
curl -X POST https://old-site.com/wp-json/auth/google \
-d "id_token=$FAKE"
# Result: I was logged in as admin@old-site.comWhy this happens
The whole thing hinges on one very common misconception: decoding a JWT is not verifying it. Until you verify its signature, a JWT is just base64url-encoded JSON. Its three parts, the header, the payload, and the signature, are separated by dots. Anyone can read the payload with a single base64_decode, no key required. That is by design. The part that proves the token was actually issued by Google is the signature, the third segment, which is signed with Google's private key.
The code above reads the payload and immediately trusts the email and sub claims inside it, without ever checking that signature at all. As a result, anyone can fabricate a payload carrying a victim's email, slap on a junk signature, and the server swallows it whole. That is the definition of a critical auth bypass: an attacker can claim to be any user whose email they know, including an admin. The "Sign in with Google" button quietly becomes "sign in as anyone".
What makes this bug slippery is that it never produces a symptom when used honestly. A real token from Google has a correct payload, so the normal flow always passes. The bug only surfaces when someone deliberately sends a lying token, and there is not a single check standing in the way.
The fix
The rule is simple: never trust a single claim before the token is verified, and that verification must happen on the server. Four things have to be checked before you map the email to a local user.
- The signature must be valid against Google's public keys (Google's JWKS / public certs). This proves the token really came from Google and was not tampered with.
audmust equal your application's OAuth client ID exactly. This proves the token was minted for your app and not someone else's.issmust beaccounts.google.comorhttps://accounts.google.com. This proves the issuer is actually Google.expmust still be in the future, i.e. the token has not expired.
And one important thing: do not hand-roll JWT verification. Use Google's official verification library (their token-info / verifier), which already handles fetching and rotating the JWKS keys correctly. The corrected version looks roughly like this:
function login_with_google( $id_token ) {
// Use Google's official verifier, not a manual decode
$client = new Google_Client( [ 'client_id' => GOOGLE_CLIENT_ID ] );
// verifyIdToken checks the signature, aud, iss, and exp together.
// It returns the payload ONLY if all are valid; otherwise false.
$payload = $client->verifyIdToken( $id_token );
if ( ! $payload ) {
return new WP_Error( 'invalid_token', 'Invalid Google token.', [ 'status' => 401 ] );
}
// Explicit checks as an extra safety net
$valid_iss = in_array(
$payload['iss'],
[ 'accounts.google.com', 'https://accounts.google.com' ],
true
);
if ( $payload['aud'] !== GOOGLE_CLIENT_ID || ! $valid_iss ) {
return new WP_Error( 'invalid_token', 'Audience/issuer mismatch.', [ 'status' => 401 ] );
}
// Only now is the email safe to trust
$email = $payload['email'];
$user = get_user_by( 'email', $email );
if ( ! $user ) {
return new WP_Error( 'no_user', 'User not found.', [ 'status' => 404 ] );
}
wp_set_auth_cookie( $user->ID );
return $user;
}Notice that the email being used comes from the verified payload, not from a parameter the client sent. This point is often missed. Never accept the email as a query param or a body field the client can freely set, because that just opens a new back door of its own.
The takeaway
An ID token proves identity only after you verify its signature, audience, issuer, and expiry. Decoding a JWT is trivial, anyone can do it, and it proves nothing. Verification is what proves something. Skipping that step turns a professional-looking Google login into an open gate that admits anyone willing to type someone else's email. If you are touching any OAuth or JWT flow, ask one question before moving on: on which exact line of code is this signature verified? If you cannot point to it, it is not there.
