google-signin · git:20261020.6a7c3e1 · 2026-10-20 · sha256 a2c276c7ba101c87

google-signin git:20261020.6a7c3e1A

Immutable. This exact content never changes and is served at /api/v1/blob/a2c276c7ba101c87.

---
name: google-signin
description: >-
  Use when implementing or debugging Google sign-in on a website: the GIS button or One Tap,
  server-side ID token checks, linking Google to password accounts, login CSRF. Not for a
  server authenticating to Google (google-auth). Opt out: no wiring, без обвязки. Triggers -
  "sign in with google", "google login", "GIS", "one tap", "account linking", "вход через
  Google", "связать аккаунты".
license: MIT
---

# 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: choose the audience.
   External Testing normally limits access to test users, but basic identity
   scopes (`openid`, `email`, `profile`) are exempt. Workspace policy can still
   block access; publishing alone is not the remedy. See the dated setup note
   in [the full guide](references/full-guide.md).
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 | Check requested scopes, audience and Workspace policy. Testing allowlisting applies beyond basic identity scopes; investigate before changing publishing status |
| 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 |

## Degradation

- **Not Claude Code** (Cursor, Codex, the skills CLI, the API container): the pack's
  `PreToolUse` gate does not run — the rules here hold, the refusal does not.
- **No browser channel**: the sign-in button and its redirect are verified by reading
  the code, not by watching it work. Record that as the weaker claim it is.
- **No console access**: creating the OAuth client is a human step; state it once, do
  the rest, and say what is waiting on it.

## Triggers

More phrases that route here: "google sign-in button", "google.accounts.id", "gsi/client",
"verify google token", "login csrf", "g_csrf_token", "кнопка входа Google", "проверить токен
Google", "origin is not allowed for the given client ID".