Previous Article
.htaccess Rewrite and Redirect Rules: A Practical Reference
2 June 2018
"Sign in with Google" removes the biggest point of friction in any signup form. It is also an area where a small implementation shortcut turns into a full account takeover, so it is worth doing carefully.
This guide covers social login as it should be built in 2026: the Authorization Code flow with PKCE, the parameters that make it safe, and the specific traps in each provider.
The distinction matters. OAuth 2.0 grants your application permission to access a resource on someone's behalf — "let this app read my calendar". It was never designed to tell you who the user is.
OpenID Connect (OIDC) is a thin layer on top of OAuth 2.0 that adds exactly that: an id_token, a signed JWT containing verified claims about the user. When you implement "social login" you want OIDC, not bare OAuth.
Google and Facebook both support OIDC. X (formerly Twitter) supports OAuth 2.0 with an authorisation code flow and a /2/users/me endpoint rather than a true id_token.
code_verifier never leaves your control, so an intercepted authorisation code is useless on its own. Token exchange happens server-side, so the client secret is never exposed to the browser.Without it, an attacker can complete their own authorisation on their own account, capture the resulting code, and then trick a logged-in victim into visiting your callback URL with that code. Your server dutifully links the attacker's social identity to the victim's session.
Generate an unguessable value, store it in the session, and reject any callback whose state does not match:
// Before redirecting
$_SESSION['oauth_state'] = bin2hex(random_bytes(32));
// In the callback — compare in constant time
if (!hash_equals($_SESSION['oauth_state'] ?? '', $_GET['state'] ?? '')) {
http_response_code(400);
exit('Invalid state');
}
unset($_SESSION['oauth_state']); // single use
Proof Key for Code Exchange started as a mobile protection and is now recommended for all clients, confidential ones included. You generate a random code_verifier, send its SHA-256 hash as the code_challenge, and present the original verifier when exchanging the code.
$verifier = rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$_SESSION['pkce_verifier'] = $verifier;
$url = 'https://accounts.google.com/o/oauth2/v2/auth?' . http_build_query([
'client_id' => $clientId,
'redirect_uri' => $redirectUri,
'response_type' => 'code',
'scope' => 'openid email profile',
'state' => $_SESSION['oauth_state'],
'nonce' => $_SESSION['oidc_nonce'],
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
]);
Note hash('sha256', $verifier, true) — the third argument returns raw binary. Passing hex here is a common bug that produces a challenge the provider will reject.
This step must happen server-side. It requires your client secret, and anything in browser JavaScript is public.
$response = $http->post('https://oauth2.googleapis.com/token', [
'form_params' => [
'code' => $_GET['code'],
'client_id' => $clientId,
'client_secret' => $clientSecret,
'redirect_uri' => $redirectUri,
'grant_type' => 'authorization_code',
'code_verifier' => $_SESSION['pkce_verifier'],
],
]);
$tokens = json_decode((string) $response->getBody(), true);
An id_token is a JWT. Decoding the payload without verifying the signature means accepting whatever anyone sends you. Use a library and check every claim:
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
$jwks = json_decode(file_get_contents('https://www.googleapis.com/oauth2/v3/certs'), true);
$claims = JWT::decode($tokens['id_token'], JWK::parseKeySet($jwks));
if ($claims->iss !== 'https://accounts.google.com') throw new RuntimeException('Bad issuer');
if ($claims->aud !== $clientId) throw new RuntimeException('Bad audience');
if ($claims->exp < time()) throw new RuntimeException('Expired');
if ($claims->nonce !== $_SESSION['oidc_nonce']) throw new RuntimeException('Bad nonce');
Cache the JWKS rather than fetching it on every login — providers rotate keys, but not per-request.
This is the mistake that turns social login into account takeover, and it is extremely common.
The tempting logic is: look up the user by the email the provider returned, and if a matching account exists, log them in. The problem is that not every provider verifies email addresses. If an attacker can create an account at any provider you trust using victim@example.com without proving ownership, they log straight into the victim's account on your site.
Two rules:
email_verified in the id_token. If it is false, treat the email as untrusted.(provider, sub) as the unique identifier. Emails change; sub does not.CREATE TABLE social_identities ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, provider VARCHAR(32) NOT NULL, subject_id VARCHAR(255) NOT NULL, email VARCHAR(255) NULL, created_at DATETIME NOT NULL, UNIQUE KEY uniq_provider_subject (provider, subject_id), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE );
When the email is unverified, or already belongs to an account created by another method, send a confirmation email before linking. It is one extra step that closes the hole entirely.
A full-page redirect is simpler and more reliable — no popup blockers, no cross-window messaging, works everywhere. Prefer it.
If you use a popup to preserve unsaved page state, the callback page must message the opener, and you must check the origin:
// In the popup callback page
window.opener.postMessage({ type: 'oauth:done' }, 'https://yourapp.com');
window.close();
// In the parent
window.addEventListener('message', (event) => {
// Without this check, any site that opened your page can forge the message.
if (event.origin !== 'https://yourapp.com') return;
if (event.data?.type === 'oauth:done') location.reload();
});
Never pass tokens through postMessage. Signal completion only; the session lives in an HttpOnly cookie your server set during the exchange.
Create credentials in Google Cloud Console under APIs & Services → Credentials → OAuth client ID. Scopes openid email profile cover login. Redirect URIs must match exactly, including trailing slash. Google Identity Services is the current front-end library; the older Google Sign-In JavaScript platform library is retired.
Register at developers.facebook.com. Facebook Login requires App Review before it works for users other than app admins — build the flow expecting that gate. Note that Facebook may return no email at all if the user registered by phone number, so your code must handle a missing email rather than assuming one.
The most important thing to know: X's API is no longer free for this. Since 2023 it has been a paid product, and the old dev.twitter.com OAuth 1.0a endpoints referenced in older tutorials are gone. Use OAuth 2.0 with PKCE against https://twitter.com/i/oauth2/authorize, then call /2/users/me. Given the cost and that X does not reliably return an email address, consider whether it earns its place next to Google and Facebook.
session_regenerate_id(true)) to prevent session fixation.HttpOnly, Secure and SameSite=Lax. Lax rather than Strict, or the cookie will not be sent on the redirect back from the provider.Use the Authorization Code flow with PKCE; the implicit flow is deprecated and should not appear in new code. Always send and verify state. Exchange codes server-side so the client secret stays private. Verify the id_token signature rather than merely decoding it. And key identities on the provider's subject ID while treating unverified emails as untrusted — that one decision is the difference between convenient login and a takeover vector.
Advertisement
Written by
Amit VermaFounder / Senior Software Engineer
Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.
More articles by AmitYou might also like
We ship software that scales. Let's work together.