Several of our smaller tools run on ordinary shared hosting: PHP 8, Apache, no Composer, no background workers. When one of them needed accounts, we did not want to store passwords at all. Google Identity Services gives you a "Sign in with Google" button that hands your page a signed ID token, and from there the only job on the server is to decide whether that token is genuine.
That last part is where most of the risk sits, so this post is about doing it carefully with no library.
How the flow works
The browser loads Google's script and renders the button. When the user picks an account, Google calls your JavaScript callback with a credential. That credential is a JWT, a signed ID token describing the user. Your page posts it to your own server, the server verifies it, and if it is valid you start your own session.
google.accounts.id.initialize({
client_id: CONFIG.googleClientId,
callback: ({ credential }) => api("api/login", { credential }),
});The important idea is that the browser is not trusted. Anyone can post any string to /api/login. The server must prove the token came from Google, was issued for your app, and has not expired.
Option A: let Google verify it
For a low-traffic app, the simplest correct approach is Google's tokeninfo endpoint. You send the token and Google answers with its decoded claims if the signature is valid, or an error if not.
$ch = curl_init('https://oauth2.googleapis.com/tokeninfo?id_token='
. rawurlencode($credential));
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10]);
$res = curl_exec($ch);
if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) !== 200) return null;
$t = json_decode((string)$res, true);This costs one HTTPS round trip per sign-in, which is nothing for a tool where people log in once a month. Google's own documentation recommends verifying locally against their public keys for production-scale traffic, and that is the right move once sign-ins are frequent. You fetch the keys once, cache them, and check the RS256 signature yourself. The claim checks below are the same either way.
The five checks
A valid signature only proves Google issued the token. It does not prove Google issued it to you. We check all of these, and reject the sign-in if any one fails:
- Issuer.
issmust beaccounts.google.comorhttps://accounts.google.com. Google uses both forms. - Audience.
audmust equal your own OAuth client ID. This is the check people forget, and it is the one that matters most. Without it, a token that any other website obtained for its own Google sign-in would also log the user into yours. - Verified email.
email_verifiedmust be true. Note thattokeninforeturns it as the string"true", not a boolean, so compare the string. - Expiry.
expmust be in the future. Google tokens live for about an hour; a captured old token should not work. - Stable identity. Key your user records on
sub, Google's permanent account ID, not on the email address. People change email addresses;subnever changes.
In code it is a single expression:
$iss = in_array($t['iss'] ?? '', ['accounts.google.com', 'https://accounts.google.com'], true);
return $iss
&& ($t['aud'] ?? '') === $CLIENT_ID
&& ($t['email_verified'] ?? '') === 'true'
&& (int)$t['exp'] > time() ? $t : null;Your own session, done properly
Once the token checks out, stop using it. Create your own random session ID, store which sub it belongs to, and give the browser a cookie:
$sid = bin2hex(random_bytes(24));
setcookie('sid', $sid, [
'expires' => time() + 2592000, // 30 days
'path' => $base . '/',
'httponly' => true,
'samesite' => 'Lax',
'secure' => $https,
]);Each flag earns its place:
httponlymeans JavaScript on the page cannot read the cookie, so an injected script cannot steal the session.samesite=Laxmeans the cookie is not sent on cross-site form posts, which blocks the classic CSRF attack on state-changing endpoints.securekeeps it off plain HTTP. We compute$httpsfrom the request, including theX-Forwarded-Protoheader, because some shared hosts terminate TLS in front of PHP.pathscopes the cookie to the app's folder when several apps share one domain.
Logging out deletes the server-side session entry as well as the cookie. Deleting only the cookie leaves a valid session that anyone holding a copy of the cookie could keep using.
A safe development shortcut
Testing Google sign-in on localhost is fiddly, so our apps have a development login that skips Google entirely. It is guarded twice: it must be switched on in the config, and the request must come from 127.0.0.1 or ::1 with no X-Forwarded-For header. If a misconfigured server ever enabled the flag in production, the second check would still refuse every real visitor.
What we deliberately do not store
We keep the user's Google sub, name, email and profile picture URL, and nothing else from the token. We never see a password, and we do not request any Google API scopes, so there is no access token to leak. For a small product that is the best kind of security: the most sensitive data is data you never had.
Summary
Sign in with Google needs no SDK on the server. Verify the token with Google or with Google's public keys, then check the issuer, the audience, the verified email and the expiry, and key users on sub. After that, throw the token away and run an ordinary, well-configured session cookie. It is about forty lines of PHP, and every line is one you can read and reason about.
