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)在網頁上整合 Google 登入。它優先建議權杖不進入瀏覽器 JavaScript 的後端中介方案,並提醒不要再使用已停用的 gapi.auth2。
運作方式
- 選擇架構:後端代理前端(BFF,建議)、
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 登入,或檢視既有的整合程式碼。
限制
僅限網頁:不含舊版 Google Sign-In、服務帳戶與 Android/iOS 原生。伺服器端範例為 Python,使用 Node、Go、Java 時需自行改寫。
低風險:純指令與範例程式碼檔,來自 Google 官方 Skills 儲存庫(Apache-2.0)。沒有腳本,自身不連網、不寫入檔案。你需要自己的 Google OAuth 用戶端 ID,範例只示範它放在哪裡。範例只是起點,並非經過稽核的程式碼:FastAPI 範例中的 nonce 只在帶有 nonce cookie 時才驗證,請在自己的程式碼中改為必須驗證;所有驗證相關程式碼上線前都應自行審查。