Google OAuth 2.0 PKCE flow in a React/Next.js app β no backend, no client secret
Google OAuth 2.0 PKCE Flow in a React/Next.js App β No Backend, No Client Secret
Current Situation Analysis
Implementing Google OAuth 2.0 in Single Page Applications (SPAs) or Next.js projects introduces significant friction due to architectural mismatches between traditional OAuth flows and modern frontend constraints. Google's official documentation is comprehensive but dense, while most community tutorials omit critical security and lifecycle management steps.
Traditional approaches fail in SPA environments for three core reasons:
- Implicit Flow Deprecation: Returning access tokens directly in URL fragments exposes credentials to browser history, referrer headers, and intermediate proxies. The OAuth Security BCP explicitly deprecates this pattern.
- Client Secret Impossibility: The standard Authorization Code flow requires a
client_secret. In a browser environment, any "secret" shipped in the JavaScript bundle is publicly readable, rendering it cryptographically worthless. - Token Lifecycle Blind Spots: Most tutorials skip
refresh_tokenacquisition, silent mid-session renewal, and dual-authentication orchestration (e.g., Google for Drive API + Supabase for app identity). This leads to broken sessions after ~1 hour, forced re-authentication, and fragmented state management.
PKCE (Proof Key for Code Exchange) resolves these architectural gaps by replacing static secrets with ephemeral cryptographic challenges, enabling secure, server-assisted token exchange without compromising frontend security boundaries.
WOW Moment: Key Findings
| Approach | Security Posture | Refresh Token Support | SPA Compatibility | Token Lifecycle Overhead |
|---|---|---|---|---|
| Implicit Flow | β Deprecated (URL fragment exposure) | β None | β High | π΄ Manual polling required |
| Auth Code + Client Secret | β οΈ Medium (Secret leakage risk) | β Yes | β Low (Requires backend proxy) | π‘ Server-side management |
| PKCE Flow | β High (Ephemeral verifier) | β
Yes (with offline + consent) | β High | π’ Automated silent refresh |
Key Findings & Sweet Spot:
- PKCE eliminates static credential storage by generating a unique
code_verifierper session, hashed into acode_challengesent to Google. access_type=offline+prompt=consentis the cryptographic trigger that guarantees a freshrefresh_tokenon every authorization, bypassing Google's consent caching.- Silent token refresh scheduled at ~55 minutes prevents the 1-hour access token expiration from interrupting user workflows.
- Decoupling Google OAuth (API access) from Supabase Auth (identity/session) creates two independent, non-interfering lifecycle boundaries.
Core Solution
Step 1: Generate the Code Verifier and Challenge
// lib/auth.js
function generateRandomString(length) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
const array = new Uint8Array(length)
crypto.getRandomValues(array)
return Array.from(array).map(byte => chars[byte % chars.length]).join('')
}
export function generateVerifier() {
return generateRandomString(64) // Must be 43β128 chars
}
export async function generateChallenge(verifier) {
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
const digest = await crypto.subtle.digest('SHA-256', data)
// base64url encode (different from regular base64 β no +, /, or = chars)
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
Step 2: Build the Authorization URL
export async function buildAuthURL() {
const verifier = generateVerifier()
const challenge = await generateChallenge(verifier)
// Store verifier for later β must survive the redirect
sessionStorage.setItem('pkce_verifier', verifier)
const params = new URLSearchParams({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
redirect_uri: `${window.location.origin}/auth/callback`,
response_type: 'code',
scope: 'openid email profile https://www.googleapis.com/auth/drive.file',
code_challenge: challenge,
code_challenge_method: 'S256',
access_type: 'offline', // β Required to receive a refresh_token
prompt: 'consent', // β Required to actually receive the refresh_token (see below)
login_hint: invitedEmail, // β Optional: pre-fills the email field
})
return `https://accounts.google.com/o/oauth2/v2/auth?${params}`
}
Critical Configuration: access_type=offline and prompt=consent
access_type=offline signals Google to issue a refresh token. However, Google caches prior consent and will not issue a new refresh token on subsequent logins. prompt=consent forces the consent screen to reappear, guaranteeing a fresh refresh token. Omitting either parameter results in a short-lived access token only, causing session
failure after ~1 hour.
Step 3: Handle the Callback & Exchange Code
// app/auth/callback/route.js
import { NextResponse } from 'next/server'
export async function GET(request) {
const { searchParams } = new URL(request.url)
const code = searchParams.get('code')
const error = searchParams.get('error')
if (error || !code) {
return NextResponse.redirect(new URL('/auth-error', request.url))
}
// Get the verifier from the cookie (set during the redirect step)
const verifier = request.cookies.get('pkce_verifier')?.value
if (!verifier) {
return NextResponse.redirect(new URL('/auth-error', request.url))
}
const tokens = await exchangeCode(code, verifier, request)
// Clean up the verifier cookie
const response = NextResponse.redirect(new URL('/log', request.url))
response.cookies.delete('pkce_verifier')
// Store the access_token in a session cookie (short-lived)
response.cookies.set('g_access_token', tokens.access_token, {
httpOnly: true,
secure: true,
maxAge: 3600
})
// The refresh_token and id_token need further handling β see below
return response
}
async function exchangeCode(code, verifier, request) {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET, // Used server-side only
redirect_uri: `${new URL(request.url).origin}/auth/callback`,
grant_type: 'authorization_code',
code_verifier: verifier,
})
})
if (!response.ok) throw new Error('Token exchange failed')
return response.json()
// Returns: { access_token, refresh_token, id_token, expires_in, token_type }
}
Verifier Persistence Strategy: The verifier is generated client-side but required server-side during callback. The cleanest architecture uses a secure, short-lived cookie:
// In your sign-in button handler
export async function startSignIn() {
const verifier = generateVerifier()
const challenge = await generateChallenge(verifier)
// Set as a cookie so the server callback can read it
document.cookie = `pkce_verifier=${verifier}; path=/; secure; samesite=lax; max-age=300`
const authUrl = await buildAuthURL(verifier, challenge)
window.location.href = authUrl
}
Step 4: Token Routing & Supabase Bootstrap
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
)
// After token exchange β pass the id_token to Supabase
const { data, error } = await supabase.auth.signInWithIdToken({
provider: 'google',
token: googleIdToken,
})
This decouples authentication boundaries: Google OAuth manages Drive/API access (access_token + refresh_token), while Supabase manages app identity. The two lifecycles operate independently post-bootstrap.
Step 5: Silent Token Refresh
// lib/auth.js
export async function refreshAccessToken() {
// Read the refresh token from SQLite settings
const refreshToken = db.getOne(
'SELECT google_refresh_token FROM settings WHERE id = 1'
)?.google_refresh_token
if (!refreshToken) {
// No refresh token β user needs to sign in again
redirectToSignIn()
return null
}
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
refresh_token: refreshToken,
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
grant_type: 'refresh_token',
})
})
if (!response.ok) {
// Refresh token revoked β show reconnect banner, don't break the app
showReconnectBanner()
return null
}
const { access_token } = await response.json()
// Store in memory (or sessionStorage) for Drive calls
setAccessToken(access_token)
return access_token
}
Schedule this execution at ~55 minutes post-login to preempt the 1-hour expiration window without UI interruption.
Pitfall Guide
- Relying on Implicit Flow: Returning tokens in URL fragments exposes credentials to browser history and referrer leakage. The OAuth Security BCP explicitly deprecates this pattern for SPAs.
- Omitting
access_type=offlineorprompt=consent: Without both parameters, Google caches consent and only returns a short-lived access token. Your application will silently break after ~1 hour when the token expires. - Client-Side Code Exchange: Performing the token exchange in the browser exposes the
client_idandcode_verifierto the DOM. Always route the exchange through a serverless function or API route to maintain the PKCE security boundary. - Persistent Storage in
localStorage: Access tokens are short-lived and highly sensitive.localStoragesurvives browser restarts and is vulnerable to XSS. UsesessionStorage, in-memory state, orhttpOnlycookies instead. - Verifier Loss Across Redirects: The
code_verifiermust survive the OAuth redirect to Google and back. Relying on component state orsessionStoragealone can cause race conditions. Use a secure,SameSite=Laxcookie with a shortmax-age(e.g., 300s) to bridge client-server boundaries. - Hardcoded Refresh Timing Without Grace Period: Access tokens expire at exactly 3600 seconds. Scheduling refresh at 60 minutes causes race conditions. Implement a 5-minute grace period (refresh at ~55 minutes) and handle
401responses with exponential backoff retry logic.
Deliverables
π¦ PKCE OAuth Blueprint A complete architecture diagram detailing the client-server token exchange boundary, cookie lifecycle management, and dual-auth (Google + Supabase) state synchronization flow. Includes timing diagrams for silent refresh and consent caching bypass.
β Pre-Flight Implementation Checklist
- Generate
code_verifier(43β128 chars) usingcrypto.getRandomValues - Derive
code_challengeviaSHA-256+base64urlencoding - Append
access_type=offlineandprompt=consentto auth URL params - Persist verifier via secure cookie (
path=/; secure; samesite=lax; max-age=300) - Route
/auth/callbackthrough Next.js API route for server-side exchange - Store
access_tokeninhttpOnlycookie orsessionStorage(neverlocalStorage) - Persist
refresh_tokenin encrypted backend storage (e.g., SQLite/Drive) - Bootstrap Supabase session via
signInWithIdTokenusingid_token - Schedule silent refresh at T+55 minutes with graceful degradation banner
βοΈ Configuration Templates
# .env.local
NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-server-side-secret
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
// Cookie Security Template
const SECURE_COOKIE_OPTS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 3600 // Match token TTL
}
-- Refresh Token Storage Schema (SQLite/PostgreSQL)
CREATE TABLE user_oauth_state (
id INTEGER PRIMARY KEY,
google_refresh_token TEXT NOT NULL,
supabase_user_id UUID REFERENCES auth.users(id),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
