Add Google Sign‑In to Next.js with OAuth 2.0 + OIDC (PKCE)

Rohit Ramachandran avatarRohit Ramachandran
Google login flow diagram for Next.js with OAuth 2.0 and OIDC

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_accounts link 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)

  1. Create a project in Google Cloud Console (or pick an existing one).
  2. OAuth consent screen → User Type: External → fill App info → Save.
  3. Add Test users (emails you’ll use for dev) while in Testing status.
  4. Credentials → Create credentials → OAuth client ID → Application type: Web.
  5. Authorized JavaScript origins:
    • http://localhost:3000
  6. Authorized redirect URIs:
    • http://localhost:3000/api/auth/callback/google
  7. 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.
  • state binds the response to the initiating browser.
  • nonce prevents 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

  1. Ensure your Google OAuth app is in Testing and your test email is added.
  2. Start dev: npm run dev (from frontend/).
  3. Visit /sign-in → “Continue with Google”.
  4. Consent → redirected to /account.
  5. 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_accounts table 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_SECRET on 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.local has Google credentials and PUBLIC_APP_URL.
  • [ ] oauth_accounts migration 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.