# Google Signin

> Use when implementing, reviewing or debugging Google login / sign-in / sign-up on a website — wiring the GIS button or One Tap, verifying Google ID tokens on the server, linking Google to existing password accounts, or fixing "origin is not allowed for the given client ID". Covers GCP OAuth client setup, backend ID-token verification, three-way account linking with the pre- hijacking guard, login-CSRF defense, nonce/replay protection and a mandatory security checklist. Triggers - "sign in with google", "google login", "google sign-in button", "GIS", "google.accounts.id", "gsi/client", "verify google token", "one tap", "account linking", "login csrf", "g_csrf_token", "вход через Google", "кнопка входа Google", "связать аккаунты", "проверить токен Google". For the broader library surface use google-auth instead.

- Skill: `ssheleg/google-signin` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add ssheleg/google-signin`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ssheleg/google-signin/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- License: MIT
- Author: ssheleg (https://skillmd.com/u/ssheleg)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ssheleg/google-signin

---


# 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](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)

1. 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),
   then `google.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.
2. 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.
3. Backend: verify with the official lib — Python
   `google.oauth2.id_token.verify_oauth2_token(credential, transport, CLIENT_ID)`,
   Node `google-auth-library` `verifyIdToken({ idToken, audience })`. That
   checks signature (Google JWKS), `aud`, `iss`, `exp`. Never hand-decode
   and trust the payload.
4. Additionally require `email_verified == true` and **pop the expected nonce
   from the pre-auth session (one-time consume) and require the token `nonce`
   claim 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.
5. 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:

1. Record with this `google_id` exists → login (refresh name/picture).
2. Email matches an existing account without `google_id` → **do NOT auto-link
   on `email_verified` alone.** `email_verified` is 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_verified` AND (the address is
     `@gmail.com` OR the token carries an `hd` Workspace-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.
3. No match → create the user. Mark `email_verified=True` only when Google is
   authoritative for the address (Gmail or `hd`); 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_uri` auto-POST, `application/x-www-form-urlencoded`):
  GIS double-submits `g_csrf_token` in 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`): no `g_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 exact `Origin` in an
  allowlist. A **missing `Sec-Fetch-Site` is NOT assumed same-origin**;
  `cross-site` and `none` are 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)

1. Cloud Console → APIs & Services → OAuth consent screen (External;
   publish it — in "Testing" only allowlisted users can sign in).
2. 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.
3. 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.
4. CSP if present: allow `https://accounts.google.com` in `script-src`,
   `connect-src`, `frame-src`.
5. If `GOOGLE_CLIENT_ID` is 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; `exp` enforced
- [ ] `email_verified` required
- [ ] Nonce: fresh per render, checked server-side against the token claim
- [ ] Pre-hijacking guard on email-based linking
- [ ] Login-CSRF: `g_csrf_token` double-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`/`ver` claim
      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 |

