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를 쓰지 말라고 경고합니다.
동작 방식
- 아키텍처 선택: 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 로그인 추가, 기존 연동 코드 검토.
한계
웹 전용입니다. 구형 Google Sign-In, 서비스 계정, Android/iOS 네이티브는 다루지 않습니다. 서버 예제는 Python이므로 Node, Go, Java에서는 직접 옮겨야 합니다.
낮은 위험: Google 공식 Skills 저장소(Apache-2.0)의 지침 파일과 예제 코드만 들어 있습니다. 스크립트가 없고 자체 네트워크 접속과 파일 쓰기도 없습니다. Google OAuth 클라이언트 ID는 직접 준비해야 하며, 예제는 그 값이 들어갈 위치만 보여 줍니다. 예제는 출발점일 뿐 감사를 거친 코드가 아닙니다. FastAPI 예제는 nonce 쿠키가 있을 때만 nonce를 검증하므로, 자신의 코드에서는 이 검증을 필수로 바꾸세요. 인증 관련 코드는 배포 전에 반드시 직접 검토하세요.