Architecture: Backend-mediated (BFF), with Express owning the session
You have a backend, so don't keep tokens in the browser. Pick one of two flows.
Option A: Next.js calls Express (recommended for a custom UI)
- Load GIS (
https://accounts.google.com/gsi/client) in Next.js with<Script strategy="afterInteractive">or anonloadloader. Don't poll. - Generate a random
nonceserver-side. Store it in the session, or in a short-lived HttpOnly cookie, and pass it togoogle.accounts.id.initialize({ client_id, nonce, callback }). - In the callback,
POSTthecredential(the ID token) to/api/auth/googlewithcredentials: 'include'. - Express verifies the token, creates a server session, and sets an
HttpOnly; Secure; SameSite=Laxcookie. - The ID token is never stored in
localStorageorsessionStorage. The browser holds only the session cookie.
Option B: login_uri redirect
GIS POSTs the credential straight to Express (ux_mode: "redirect"). Express must check the double-submit CSRF value: the g_csrf_token cookie has to equal the g_csrf_token body field. Then it verifies the token, sets the cookie, and returns a 302. No JavaScript ever touches the token.
If Next.js and Express are on different origins, put them under one site or proxy /api through Next.js. That keeps the cookie first-party and makes SameSite=Lax work. Otherwise you need CORS with credentials, which is more fragile.
What the server must verify
Use OAuth2Client.verifyIdToken({ idToken, audience: CLIENT_ID }) from google-auth-library. Don't hand-roll JWT parsing. It checks the signature against Google's JWKS (https://www.googleapis.com/oauth2/v3/certs) and the standard claims. Make sure these hold:
| Check | Requirement |
|---|---|
| Signature | Valid against Google's current public keys (the library caches and rotates them). |
iss |
https://accounts.google.com or accounts.google.com. |
aud |
Exactly your OAuth client ID. This is the most commonly missed check. |
exp |
Not expired. |
nonce |
Matches the value you issued for this session. It's one-time use, so delete it after the check (this blocks replay). |
email_verified |
true before you trust or link on email. |
sub |
Use this as the stable user key. Never use email as the primary identifier, because emails can change. |
hd |
If you restrict to a Workspace domain, check the hd claim server-side. Client-side hd hints aren't enforcement. |
For the Option B flow, also check the g_csrf_token cookie against the body field, as described above.
Session and hardening
- Regenerate the session ID on login, to prevent session fixation.
- Use CSRF protection on your own state-changing endpoints.
SameSite=Laxhelps but isn't enough on its own. - Send
Cross-Origin-Opener-Policy: same-origin-allow-popups. Plainsame-originbreaks the GIS popup silently. - Set a CSP that allows
script-src https://accounts.google.com/gsi/client,frame-src https://accounts.google.com/gsi/, andconnect-src https://accounts.google.com/gsi/. - On logout, call
google.accounts.id.disableAutoSelect()and destroy the server session. Otherwise One Tap signs the user straight back in. - Don't use the legacy
gapi.auth2orgapi.signin2. They're retired. Use GIS only. - Don't trust any user info the client sends alongside the token. Take identity only from the verified payload.
Express sketch
const { OAuth2Client } = require('google-auth-library');
const client = new OAuth2Client(CLIENT_ID);
app.post('/api/auth/google', async (req, res) => {
const ticket = await client.verifyIdToken({
idToken: req.body.credential,
audience: CLIENT_ID,
});
const p = ticket.getPayload();
if (!p.email_verified) return res.sendStatus(401);
if (p.nonce !== req.session.nonce) return res.sendStatus(401);
delete req.session.nonce;
// if (p.hd !== 'yourdomain.com') return res.sendStatus(403);
const user = await upsertUserBySub(p.sub, p.email);
req.session.regenerate(() => {
req.session.userId = user.id;
res.sendStatus(204);
});
});
This is a sketch. Wrap verifyIdToken in try/catch and return 401 on failure. Also make sure regenerate doesn't lose the nonce check order.
I can wire this into your project if you'd like. It would help to know whether Next.js and Express share an origin.
Pure-instruction skill, so the trial gave the model only the Skill and Read tools. The generated Express sketch was not run or tested; the model itself says to wrap verification in try/catch and check the nonce handling order. Review all authentication code before using it.
できること
Google Identity Services(GIS)による Web の Google ログイン実装をモデルに案内します。トークンをブラウザの JavaScript に渡さないバックエンド経由の構成を優先し、廃止された gapi.auth2 を使わないよう警告します。
仕組み
- 構成の選択:Backend For Frontend(推奨)、
login_uriへのフォーム POST リダイレクト(推奨)、静的サイトやオフライン PWA 向けのクライアントのみの代替案。 - 必須のブラウザ側設定:CSP、
Cross-Origin-Opener-Policy: same-origin-allow-popups、FedCM と One Tap の挙動、サインアウト時のdisableAutoSelect()、iframe でのidentity-credentials-get権限。 - 参考実装:FastAPI での ID トークン検証(署名、audience、nonce、Workspace の
hd)、リダイレクトモードのダブルサブミット CSRF、React のメモリ保持パターン、暗号化 IndexedDB パターン、intermediate iframe。
向いている場面
React、Next.js、Angular、素の HTML サイトへの Google ログイン追加、既存実装のレビュー。
制限
Web のみが対象です。旧 Google Sign-In、サービスアカウント、Android/iOS ネイティブは対象外です。サーバー側のサンプルは Python なので、Node・Go・Java では読み替えが必要です。
低リスク:Google 公式の Skills リポジトリ(Apache-2.0)にある指示ファイルとサンプルコードのみで、スクリプトなし、自身の通信なし、ファイル書き込みなしです。Google OAuth のクライアント ID はご自身で用意する必要があり、サンプルはその置き場所を示すだけです。サンプルは出発点であり、監査済みのコードではありません。FastAPI の例では nonce cookie がある場合にだけ nonce を検証するため、自分のコードでは必須の検証にしてください。認証まわりのコードは公開前に必ず自分で確認してください。