Adding third-party sign-in looks like a solved problem: take an OIDC library, exchange the code, verify the signature, find the user by email. Wiring up the first two providers feels exactly like that.
The third one is where it breaks. “Both are OIDC” does not mean both assert the same things — what a provider actually promises inside its ID token varies enough to turn working code into an account-takeover hole. What follows came out of wiring Google, Google One Tap and Microsoft into this site. All of it is in the code.
What the thing looks like
A WordPress site with its own email-and-password accounts — readers need to comment and receive notifications — plus third-party sign-in layered on top. The goal is unremarkable: a reader who already has an account here should land in that account when they sign in with a provider, not acquire a second one.
That sentence — “land in that account” — is where the entire risk lives.
Microsoft does not tell you whether the address was verified. Google does.
Google’s ID token carries email_verified. That claim is the whole reason linking a Google identity to a pre-existing site account by address is safe: Google is vouching that the address belongs to whoever just signed in.
$emailVerified = $payload['email_verified'] ?? false;
$emailVerified = $emailVerified === true || $emailVerified === 'true'
|| $emailVerified === '1' || $emailVerified === 1;
if ($email === '' || ! is_email($email) || ! $emailVerified) {
return new WP_Error('email_not_verified', 'Google email is not verified.');
}
(A small trap in passing: depending on the source, this claim arrives as boolean true or as the string "true". A bare === true rejects legitimate sign-ins; a bare if ($payload['email_verified']) accepts the string "false". Listing all four forms is the cheapest correct thing to write.)
When adding Microsoft, the natural move is to copy that block and rename the variables. Microsoft’s ID token has no email_verified claim at all.
For personal MSA accounts in particular, Microsoft has never asserted that the address was verified. Copying Google’s link-by-email behaviour therefore means: anyone who registers an MSA using someone else’s address, and clicks “sign in with Microsoft”, lands inside that person’s site account. No password, no confirmation mail — because nothing anywhere in that chain ever checked the address.
So the Microsoft path links by sub only, and refuses outright when the address already belongs to an account:
// Deliberately NOT the Google behaviour. Microsoft asserts no
// email_verified claim, so an address matching an existing account
// proves nothing and must not grant access to it.
$existing = email_exists($email);
if (is_int($existing) && $existing > 0) {
return new WP_Error('ms_email_taken', 'That email already belongs to a site account.');
}
The reader gets told to sign in normally and connect Microsoft from their profile page. That is deliberately less convenient than the Google path, and it is the only safe reading of what Microsoft actually asserts.
Stated generally: linking a third-party identity to an existing account is justified by someone vouching for the address, not by the address matching. No vouching, no linking — create a new account, or make the user prove who they are first.
Under the common tenant, the issuer is not a constant
Every ID-token checklist includes “compare iss“, and most tutorials hand you a hard-coded string to compare against. With Microsoft’s common authority, iss varies by tenant, so a fixed string can never match — and deleting the check because it “doesn’t work” throws away issuer validation entirely.
The right move is to rebuild the issuer from the tid claim and compare that:
$tid = (string) ($payload['tid'] ?? '');
if ($tid === '' || ! preg_match('/^[A-Za-z0-9\-]{1,64}$/', $tid)) {
return new WP_Error('invalid_id_token', 'Missing Microsoft tenant claim.');
}
if ((string) ($payload['iss'] ?? '')
!== sprintf('https://login.microsoftonline.com/%s/v2.0', $tid)) {
return new WP_Error('invalid_id_token', 'Invalid Microsoft ID token issuer.');
}
That regex on tid is not fastidiousness. The value comes out of the token being validated, and the next line splices it into the string it will be compared against — anything taken from the object under validation and used to build the thing it is validated against has to be constrained first, or the check degenerates into comparing a value with itself.
Microsoft publishes JWKs, not PEMs — but the certificate is already in there
Google’s oauth2/v1/certs hands you PEM certificates that openssl_verify accepts directly. Microsoft publishes a JWKS, where keys are described as n and e — modulus and exponent.
It is easy to end up writing an RSA public key encoder by hand from there: ASN.1 length prefixes, the leading zero byte when the high bit is set, several dozen lines of code that is unpleasant to debug.
None of that is necessary. Every Microsoft key carries x5c — the DER certificate chain, base64 encoded — so wrapping it in PEM delimiters produces a certificate that works as is:
$keys[(string) $key['kid']] = "-----BEGIN CERTIFICATE-----\n"
. chunk_split((string) $key['x5c'][0], 64, "\n")
. "-----END CERTIFICATE-----\n";
The 64 in chunk_split is PEM’s required line width, not an arbitrary number — some OpenSSL builds reject unwrapped base64.
A per-request nonce can go neither in the HTML nor in the database
Google One Tap wants a nonce from the front end for replay protection. The standard implementation generates one server-side, prints it into the page, and compares on verification.
This site’s pages are cached twice over: nginx’s fastcgi_cache in front of PHP, Cloudflare in front of that. A per-request nonce printed into the HTML gets cached and served to every subsequent visitor — one nonce shared by everyone, which is precisely the thing it exists to prevent.
So the markup carries nothing request-specific, and the script fetches its nonce over POST. Both cache layers skip POST by method, so no extra cache rule is needed — and a rule that does not exist cannot be “optimised away” by someone later.
Which raises the next problem: that endpoint is public and unauthenticated. Storing the nonce in a transient means anyone can loop on it and write unbounded rows into wp_options, since this site has no object cache and transients land in the database.
The answer is a nonce that signs itself and is never stored:
$body = base64url(json_encode([
'n' => wp_generate_password(24, false, false),
't' => time(),
]));
// then an HMAC over that, using the site key, appended
Issuing one now costs no storage. Single use is not lost, only moved later — to the point where a valid, Google-signed token has already been presented. An attacker cannot hammer that step cheaply, because reaching it requires Google to sign something for them first.
The trade is worth stating on its own: a stateless credential does not remove state, it defers the step that needs state to a place that is expensive to reach.
One Tap fails silently
When One Tap does not appear, there is no error on the page and nothing in the console. Two separate causes both behave that way — configuration that produces something when correct and nothing at all when wrong.
First, the Google Cloud Console needs Authorised JavaScript origins. A redirect_uri alone is not enough: that is for the redirect flow. One Tap runs through the in-browser GSI client, which validates the origin. Missing it, the prompt silently never shows.
Second, current Chrome uses FedCM, and omitting use_fedcm_for_prompt: true is equally silent.
What makes this class of failure expensive is not that it is hard to fix — it is that it looks exactly like a coding mistake. I lost far more time here than I spent writing the code. The way out is to first confirm the GSI script loaded and the callback registered; if both are true and no prompt appears, the problem is configuration, not code.
Sign in with Apple is missing, and the barrier is not code
Apple never got wired up, for reasons unrelated to difficulty:
First, the Apple Developer Program costs $99 a year. Without membership there is no Services ID and no .p8 private key, so the path is simply closed.
Second, and more worth recording: Apple’s client_secret is not a fixed string but a JWT signed with ES256 that expires after at most six months. Shipping the integration therefore also means shipping a scheduled re-signing job — otherwise sign-in breaks without warning on some day half a year later, by which point you have long forgotten it exists.
Two more things to know before starting: Apple returns the user’s name only on first authorisation, and never again; and for users on private relay addresses you must register a sending domain with Apple, or mail from the site bounces.
What this design gives up
The Microsoft path really is more awkward. On an address collision the user has to sign in normally first, then connect from their profile. That is a real conversion cost, paid to keep “no vouching, no linking” from acquiring exceptions.
Linking by sub ties the identity to the provider’s account. A user who abandons that Microsoft account cannot reconnect on their own; it becomes a manual fix.
The stateless nonce defers its single-use check. The check still holds where it matters, but the issuing endpoint no longer remembers what it handed out — that is what was traded for making it unfloodable, and it is not free.
One Tap loads only for logged-out visitors, and only once the browser is idle. Keeping third-party script off the critical path is a hard requirement for mobile performance here; the cost is that some visitors never see the prompt at all.
And one thing simply stays as it is: two providers, not three. For a personal site, Apple’s $99 and its twice-yearly re-signing chore have yet to earn their keep.
