Back to KB
Difficulty
Intermediate
Read Time
7 min

Google OAuth 2.0 PKCE flow in a React/Next.js app β€” no backend, no client secret

By Codcompass TeamΒ·Β·7 min read

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:

  1. 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.
  2. 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.
  3. Token Lifecycle Blind Spots: Most tutorials skip refresh_token acquisition, 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

ApproachSecurity PostureRefresh Token SupportSPA CompatibilityToken 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_verifier per session, hashed into a code_challenge sent to Google.
  • access_type=offline + prompt=consent is the cryptographic trigger that guarantees a fresh refresh_token on 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

  1. 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.
  2. Omitting access_type=offline or prompt=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.
  3. Client-Side Code Exchange: Performing the token exchange in the browser exposes the client_id and code_verifier to the DOM. Always route the exchange through a serverless function or API route to maintain the PKCE security boundary.
  4. Persistent Storage in localStorage: Access tokens are short-lived and highly sensitive. localStorage survives browser restarts and is vulnerable to XSS. Use sessionStorage, in-memory state, or httpOnly cookies instead.
  5. Verifier Loss Across Redirects: The code_verifier must survive the OAuth redirect to Google and back. Relying on component state or sessionStorage alone can cause race conditions. Use a secure, SameSite=Lax cookie with a short max-age (e.g., 300s) to bridge client-server boundaries.
  6. 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 401 responses 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) using crypto.getRandomValues
  • Derive code_challenge via SHA-256 + base64url encoding
  • Append access_type=offline and prompt=consent to auth URL params
  • Persist verifier via secure cookie (path=/; secure; samesite=lax; max-age=300)
  • Route /auth/callback through Next.js API route for server-side exchange
  • Store access_token in httpOnly cookie or sessionStorage (never localStorage)
  • Persist refresh_token in encrypted backend storage (e.g., SQLite/Drive)
  • Bootstrap Supabase session via signInWithIdToken using id_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
);