Add Google Sign‑In to Next.js with OAuth 2.0 + OIDC (PKCE)
Add Google Sign‑In to Next.js with OAuth 2.0 + OIDC (PKCE, JWT, Drizzle)
This is a practical, end‑to‑end guide to add “Continue with Google” to a Next.js App Router project using first‑party OAuth 2.0 + OpenID Connect (OIDC). It uses:
- PKCE + state + nonce for security.
- A JWT session cookie (HttpOnly) you already use for email/password.
- Neon + Drizzle with an
oauth_accountslink table.
You’ll get dev and prod setups, exact code, and a debugging checklist. I’ll also explain the why in plain language so it’s easy to reason about and maintain.
The Story: Why I added Google Sign‑In (and didn’t reach for a big auth framework)
I wanted a login that users already trust, without the “create another password” friction. I also wanted to keep ownership of my user model (Neon + Drizzle) and my session (simple JWT cookie), instead of pulling in a heavyweight auth toolkit. Google OIDC is standards‑based, well‑documented, and works great with Next.js.
The plan: keep email/password as a baseline, then layer Google with a tiny set of routes and a small link table. Make it secure (PKCE/state/nonce), predictable (HttpOnly cookie), and easy to debug (clear steps).
Why this approach (ELI5)
- No extra heavy auth frameworks; stays close to platform docs.
- Works with your existing users table and session cookie.
- PKCE, state, and nonce protect against code interception, CSRF, and replay.
Think of it like a club entrance:
- Google is the bouncer checking IDs (OIDC).
- You get a stamped hand (ID token) proving “this is you”.
- We check the stamp’s signature (JWKS), then add you to our guest list (
users) and hand you a wristband (our session cookie). We never store your password from Google—only the link that says “this Google account belongs to this local user”.
1) Google Cloud Console setup (Dev)
- Create a project in Google Cloud Console (or pick an existing one).
- OAuth consent screen → User Type: External → fill App info → Save.
- Add Test users (emails you’ll use for dev) while in Testing status.
- Credentials → Create credentials → OAuth client ID → Application type: Web.
- Authorized JavaScript origins:
http://localhost:3000
- Authorized redirect URIs:
http://localhost:3000/api/auth/callback/google
- Create → copy the Client ID and Client Secret.
Later for production, add a second Web client with your domain:
https://yourdomain.com/api/auth/callback/google
2) Environment variables
In frontend/.env.local:
PUBLIC_APP_URL=http://localhost:3000
AUTH_SECRET=<strong random 32+ bytes>
GOOGLE_CLIENT_ID=<from console>
GOOGLE_CLIENT_SECRET=<from console>
Restart the dev server whenever you change env.
3) Database: add a provider link table (ELI5)
We already have users for email/password. For Google, we add a tiny table that says “Google user X belongs to local user Y”. No password from Google is stored, just the link and optional tokens if you decide to call Google APIs.
We keep email/password in users and link external providers in a separate table. Add this to frontend/lib/db/schema.ts:
export const oauthAccounts = pgTable('oauth_accounts', {
id: serial('id').primaryKey(),
provider: varchar('provider', { length: 50 }).notNull(),
providerUserId: varchar('provider_user_id', { length: 255 }).notNull(),
userId: integer('user_id').notNull().references(() => users.id),
email: varchar('email', { length: 255 }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
expiresAt: timestamp('expires_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
});
Run migrations (Neon):
cd frontend
npm run db:generate
npm run db:migrate
4) Routes: start and callback (PKCE + state + nonce)
Add a start route that builds Google’s authorize URL and stores short‑lived cookies:
import { NextResponse } from 'next/server';
export const runtime = 'nodejs';
function base64url(input: ArrayBuffer) { /* … */ }
async function sha256(v: string) { /* … */ }
function rand(n = 32) { /* … */ }
export async function GET() {
const clientId = process.env.GOOGLE_CLIENT_ID;
const base = process.env.NEXT_PUBLIC_BASE_URL || process.env.PUBLIC_APP_URL || '';
if (!clientId || !base) return NextResponse.json({ error: 'Google OAuth not configured' }, { status: 500 });
const redirectUri = `${base.replace(/\/$/, '')}/api/auth/callback/google`;
const state = rand(24); const nonce = rand(24); const verifier = rand(48);
const challenge = await sha256(verifier);
const params = new URLSearchParams({
client_id: clientId, redirect_uri: redirectUri, response_type: 'code',
scope: 'openid email profile', state, nonce,
code_challenge: challenge, code_challenge_method: 'S256',
access_type: 'offline', prompt: 'consent',
});
const url = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
const res = NextResponse.redirect(url);
const isHttps = base.startsWith('https://');
const opts = { httpOnly: true, sameSite: 'lax' as const, secure: isHttps, path: '/', maxAge: 600 };
res.cookies.set('g_state', state, opts);
res.cookies.set('g_nonce', nonce, opts);
res.cookies.set('g_verifier', verifier, opts);
return res;
}
Handle Google’s callback: verify state/nonce, exchange code, verify ID token, link/create user, set session, redirect:
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db/drizzle';
import { oauthAccounts, teamMembers, teams, users } from '@/lib/db/schema';
import { and, eq } from 'drizzle-orm';
import { setSession, hashPassword } from '@/lib/auth/session';
import { logActivity } from '@/lib/db/queries';
import { createRemoteJWKSet, jwtVerify, JWTPayload } from 'jose';
export const runtime = 'nodejs';
const GOOGLE_ISS = 'https://accounts.google.com';
const GOOGLE_JWKS = createRemoteJWKSet(new URL('https://www.googleapis.com/oauth2/v3/certs'));
export async function GET(request: NextRequest) {
const base = process.env.NEXT_PUBLIC_BASE_URL || process.env.PUBLIC_APP_URL || '';
const clientId = process.env.GOOGLE_CLIENT_ID;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
// 1) Validate config, read code + state, verify cookies (state/nonce/verifier)
// 2) Exchange code → tokens with code_verifier
// 3) Verify ID token signature + audience + issuer
// 4) Link or create user; set session; clear temp cookies; redirect
/* …full implementation in repo… */
}
Why it’s secure:
- PKCE protects the code exchange even on public clients.
statebinds the response to the initiating browser.nonceprevents ID token replay.- ID token signature is verified against Google JWKS; audience/issuer checked.
ID token vs access token (ELI5): the ID token proves who you are; the access token lets you call Google APIs on the user’s behalf. For simple “Sign in with Google”, we only need the ID token. We store refresh/access tokens only if we later call Google APIs.
5) UI: add a Google button on sign‑in/sign‑up
<a href="/api/auth/oauth/google/start" className="btn">Continue with Google</a>
<a href="/api/auth/oauth/google/start" className="btn">Continue with Google</a>
Add an icon at public/google.svg (optional).
6) Sessions and linking behavior
- If a Google account is already linked → sign the user in.
- If not linked but an existing user with the same verified email exists → link to that user.
- Else create a new user + team, link account, set session, and redirect to
/account.
You can change this to “link‑only” or add a “complete sign‑up” interstitial.
7) Testing (dev) — Checklist
- Ensure your Google OAuth app is in Testing and your test email is added.
- Start dev:
npm run dev(fromfrontend/). - Visit
/sign-in→ “Continue with Google”. - Consent → redirected to
/account. - Verify at
/api/user(should return your user JSON).
Troubleshooting:
- Redirect URI must match exactly.
- Restart after editing env.
- Clear cookies if you see state/nonce errors.
- Ensure
oauth_accountstable exists (run migrations).
Common errors (and fixes):
- “redirect_uri mismatch”: the URL in Google Console doesn’t exactly match your app’s callback path. Copy/paste both sides.
- “Invalid state/nonce”: likely stale cookies; try incognito or clear site data. Ensure we set and read the same cookie names.
- “Token exchange failed”: often due to wrong client secret or redirect URI; check the response body in logs.
8) Move to production
- Create a second OAuth Web client in Google Cloud with your production domain and redirect URI.
- Set env in prod (
GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,PUBLIC_APP_URL=https://yourdomain.com). - Publish your OAuth consent screen to “In production” once you have public policies and branding.
Security checklist for prod:
- Cookies:
HttpOnly,Secure(HTTPS),SameSite=Lax. - Strong
AUTH_SECRET(≥ 32 bytes) and rotation plan. - Generic auth errors (no user enumeration).
- Rate‑limit auth endpoints.
Bonus hardening:
- Short session lifetimes with silent refresh on activity.
- Rotate
AUTH_SECRETon a schedule; re‑sign sessions at next login. - Add login notifications and admin audit logs for linking/unlinking providers.
Final checklist
- [ ] Google OAuth client (dev + prod) with redirect URIs.
- [ ]
.env.localhas Google credentials andPUBLIC_APP_URL. - [ ]
oauth_accountsmigration applied. - [ ] Start + callback routes added and deployed.
- [ ] Buttons on sign‑in/sign‑up.
- [ ] Tested end‑to‑end in dev; logs are clean.
FAQ
Q: Why not store tokens and call Google APIs?
A: If you don’t need Google data (Calendar, Drive, etc.), skip it. Less data, fewer secrets.
Q: Why HttpOnly cookie and not localStorage?
A: HttpOnly cookies aren’t readable by JS, reducing XSS blast radius. They also work seamlessly with Next middleware.
Q: Can I use only Google sign‑in (no passwords)?
A: Yes. Keep users as the source of truth and rely on oauth_accounts linking. You can hide the password form entirely.