Google Sign-In (GIS) — production web login
Condensed operating instructions. Full narrative guide with diagrams, token anatomy, troubleshooting table, and a copy-paste FastAPI + JS skeleton: references/full-guide.md — read it when implementing from scratch; its FastAPI + JS skeleton is the reference implementation this skill ships.
Pick the right flow first
- Only need "who is this user?" → GIS ID-token flow (this skill): load
https://accounts.google.com/gsi/client, receive one Google-signed JWT, verify server-side. No redirect URI, no client secret, no Google-token storage. - Need to call Google APIs (Gmail/Drive/Calendar) on the user's behalf → OAuth 2.0 authorization-code flow instead (redirect URI + client secret + refresh tokens). Never use the code flow just for login.
Core flow (ID-token)
- Frontend: fetch a server-issued nonce first (
GET /api/auth/google/nonce— the server stores it with a TTL, bound to a pre-auth HttpOnly cookie), thengoogle.accounts.id.initialize({ client_id, callback, nonce })renderButton(). A client-generated nonce POSTed back proves nothing: the value would be read out of the very JWT it is meant to check. The GSI script loads async — retry rendering (e.g. 20 × 150 ms) instead of silently dropping the button.
- Callback receives
{ credential }— a Google-signed ID token (JWT). POST it alone to your backend (no nonce in the body). Never treat it as a session. - Backend: verify with the official lib — Python
google.oauth2.id_token.verify_oauth2_token(credential, transport, CLIENT_ID), Nodegoogle-auth-libraryverifyIdToken({ idToken, audience }). That checks signature (Google JWKS),aud,iss,exp. Never hand-decode and trust the payload. - Additionally require
email_verified == trueand pop the expected nonce from the pre-auth session (one-time consume) and require the tokennonceclaim to equal it exactly — a missing claim, a missing expectation, an expired or an already-consumed nonce all reject. This is what makes a stolen token non-replayable: a replay from a new session meets a different expectation, a replay in the same session meets a consumed one. - Find-or-create the user, then issue YOUR OWN session (app JWT) as an HttpOnly + Secure + SameSite=Strict cookie. Google's token is verified once, never stored, never logged.
Identity & account linking (three-way branch)
Key the user on sub (stable Google user ID; store as google_id) — never
on email (emails change, sub doesn't). On each Google login:
- Record with this
google_idexists → login (refresh name/picture). - Email matches an existing account without
google_id→ do NOT auto-link onemail_verifiedalone.email_verifiedis Google saying it verified the inbox ONCE, not that this person owns the LOCAL account, and for a third-party address Google is not even authoritative that they still own the inbox. Default: link only after a fresh re-auth of the existing local account (its password / existing factor), so the person proves they hold the account Google is being attached to.- Auto-link WITHOUT that re-auth is allowed only when Google is
authoritative for the address —
email_verifiedAND (the address is@gmail.comOR the token carries anhdWorkspace-domain claim) — AND the existing account's own email ownership was proven. For any other (third-party) address, run an independent challenge (a link to that inbox) before linking; Google's one-time verification is not current proof. - If the existing account is a password account whose email was never verified (a possible pre-registration hijack), send the person into a safe account-recovery flow — never "sign in with the existing password", which may be the attacker's.
- Auto-link WITHOUT that re-auth is allowed only when Google is
authoritative for the address —
- No match → create the user. Mark
email_verified=Trueonly when Google is authoritative for the address (Gmail orhd); for a third-party address record it unverified and challenge the inbox before granting anything that trusts the email.
Login-CSRF (cover BOTH delivery flows)
The two delivery flows are separate, explicitly typed paths, chosen by Content-Type — not one handler that claims both. A skeleton that declares form support but only binds a JSON body does not actually run the form contract.
- Form-POST flow (
login_uriauto-POST,application/x-www-form-urlencoded): GIS double-submitsg_csrf_tokenin the FORM BODY and the cookie — require BOTH present and equal, constant-time (hmac.compare_digest). Missing either side → reject. This token is the whole authorization for the form path. - JS-fetch flow (
application/json): nog_csrf_token, so require a trusted same-origin signal —Sec-Fetch-Site: same-origin(browser-set, unforgeable), OR, only when that header is ABSENT, an exactOriginin an allowlist. A missingSec-Fetch-Siteis NOT assumed same-origin;cross-siteandnoneare rejected; and with neither a trusted metadata value nor an allowed Origin the request fails closed.
The CSRF decision runs BEFORE the token is verified — no external call on a request that has not proven its origin. Without this, an attacker's page can force-POST the attacker's credential and silently log the victim into the attacker's account.
Setup (GCP)
- Cloud Console → APIs & Services → OAuth consent screen (External; publish it — in "Testing" only allowlisted users can sign in).
- Credentials → Create OAuth client ID → Web application →
Authorized JavaScript origins = every origin the button renders on,
incl.
http://localhost:<port>for dev (scheme+host+port, no path, no trailing slash). Authorized redirect URIs: leave empty for GIS. - Ship only
GOOGLE_CLIENT_ID(public; env var; one source of truth — inject into HTML server-side, e.g. via a<meta>tag). The client secret is UNUSED in the GIS flow — never put it in the app. - CSP if present: allow
https://accounts.google.cominscript-src,connect-src,frame-src. - If
GOOGLE_CLIENT_IDis empty → hide the button and return "not configured" server-side (honest degradation, no crash).
Security checklist (verify ALL before calling it done)
- Signature verified via official lib against Google JWKS (no
verify=False, no manual base64 decode-and-trust) -
aud== your client ID;expenforced -
email_verifiedrequired - Nonce: fresh per render, checked server-side against the token claim
- Pre-hijacking guard on email-based linking
- Login-CSRF:
g_csrf_tokendouble-submit AND same-origin check for the JS flow - Own session cookie: HttpOnly + Secure + SameSite=Strict
-
/api/auth/*rate-limited; auth events logged WITHOUT the credential - Session revocation path exists (e.g. an
auth_version/verclaim check) - Logout also calls
google.accounts.id.disableAutoSelect()
Debugging quick table
| Symptom | Fix |
|---|---|
| "The given origin is not allowed for the given client ID" | Add the exact origin (port, www.) to Authorized JavaScript origins; propagation takes minutes |
| Button never renders | GSI async race (add retry loop), empty client ID, or CSP blocks accounts.google.com |
| "Token used too early/expired" | Server clock skew → sync NTP |
| Audience mismatch | Frontend/backend client IDs differ → single env source of truth |
access_denied on consent |
Consent screen still in Testing → publish the app |
| Works locally, breaks in prod | Prod origin missing in GCP, or Secure cookie served over plain HTTP |
| One Tap missing / FedCM console warnings | FedCM is mandatory since Aug 2025 and the opt-out is gone (checked 2026-08-06). Load gsi/client from Google and keep it current — a vendored or pinned copy is the real break. The rendered button flow is unaffected; code branching on the old isNotDisplayed() moment callbacks is not |