OAuth and SSO integration is one of the most-requested features in web applications, and one of the most error-prone to implement. The flows are standardized, but the details — PKCE for SPAs, state parameter validation, token storage, refresh rotation — get implemented wrong often enough that it’s worth getting exactly right. Claude Code generates correct OAuth flows with all the security details.
OAuth 2.0 with PKCE (SPA)
Implement Google OAuth login in our Next.js SPA.
Use PKCE flow — no client secret in the browser.
Store tokens in httpOnly cookies (set by API route, not client JS).
Frontend: Initiate OAuth Flow
// src/lib/oauth.ts
import { sha256 } from './crypto';
export async function initiateGoogleOAuth() {
// PKCE: generate code verifier and challenge
const codeVerifier = generateRandomString(64);
const codeChallenge = await sha256(codeVerifier)
.then(hash => btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''));
// State parameter: random string to prevent CSRF
const state = generateRandomString(32);
// Store in sessionStorage — only needed for the redirect back
sessionStorage.setItem('oauth_code_verifier', codeVerifier);
sessionStorage.setItem('oauth_state', state);
const params = new URLSearchParams({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
redirect_uri: `${window.location.origin}/auth/callback/google`,
response_type: 'code',
scope: 'openid email profile',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
access_type: 'offline', // Request refresh token
prompt: 'consent', // Always show consent screen (required for refresh token)
});
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
}
function generateRandomString(length: number): string {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return Array.from(array, b => b.toString(36)).join('').slice(0, length);
}
API Route: Handle Callback
// src/app/api/auth/callback/google/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { signJWT } from '@/lib/jwt';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
const state = searchParams.get('state');
const error = searchParams.get('error');
if (error || !code || !state) {
return NextResponse.redirect(new URL('/login?error=oauth_failed', request.url));
}
// Validate state (passed from frontend via cookie since we can't access sessionStorage)
const storedState = request.cookies.get('oauth_state')?.value;
if (state !== storedState) {
return NextResponse.redirect(new URL('/login?error=invalid_state', request.url));
}
const codeVerifier = request.cookies.get('oauth_code_verifier')?.value;
if (!codeVerifier) {
return NextResponse.redirect(new URL('/login?error=missing_verifier', request.url));
}
try {
// Exchange code for tokens
const tokenResponse = 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.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
redirect_uri: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback/google`,
grant_type: 'authorization_code',
code_verifier: codeVerifier,
}),
});
const tokens = await tokenResponse.json();
if (!tokenResponse.ok) throw new Error(tokens.error_description);
// Get user info from Google
const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const googleUser = await userInfoResponse.json();
// Upsert user in our database
const user = await db('users')
.insert({
email: googleUser.email,
name: googleUser.name,
avatar_url: googleUser.picture,
google_id: googleUser.sub,
email_verified: googleUser.email_verified,
created_at: new Date(),
})
.onConflict('email')
.merge(['name', 'avatar_url', 'google_id'])
.returning('*')
.then(rows => rows[0]);
// Store refresh token (encrypted)
if (tokens.refresh_token) {
await db('oauth_tokens').insert({
user_id: user.id,
provider: 'google',
refresh_token: encrypt(tokens.refresh_token), // Encrypt at rest
expires_at: new Date(Date.now() + tokens.expires_in * 1000),
}).onConflict(['user_id', 'provider']).merge();
}
// Create our session token
const sessionToken = await signJWT({ userId: user.id, email: user.email });
const response = NextResponse.redirect(new URL('/dashboard', request.url));
// Set as httpOnly cookie — not accessible to JavaScript
response.cookies.set('session', sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
path: '/',
});
// Clear OAuth state cookies
response.cookies.delete('oauth_state');
response.cookies.delete('oauth_code_verifier');
return response;
} catch (error) {
console.error('OAuth callback error:', error);
return NextResponse.redirect(new URL('/login?error=oauth_failed', request.url));
}
}
GitHub OAuth
Add GitHub OAuth for developer-focused login.
After auth, fetch user's public repos to show in their profile.
// Simple GitHub OAuth — no PKCE needed (server-side only flow)
export function getGitHubAuthUrl(state: string): string {
const params = new URLSearchParams({
client_id: process.env.GITHUB_CLIENT_ID!,
redirect_uri: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback/github`,
scope: 'user:email read:user', // public_repo if you need repo access
state,
});
return `https://github.com/login/oauth/authorize?${params}`;
}
// In callback handler:
async function exchangeGitHubCode(code: string) {
const response = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET,
code,
}),
});
const data = await response.json();
if (data.error) throw new Error(data.error_description);
// Get user info
const userResponse = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${data.access_token}` },
});
return { token: data.access_token, user: await userResponse.json() };
}
Auth0 Integration
We want to use Auth0 so we don't manage the OAuth flows ourselves.
Add Auth0 to our Next.js app with social logins + email/password.
// src/middleware.ts
import { withMiddlewareAuthRequired } from '@auth0/nextjs-auth0/edge';
// Protect all /dashboard/* routes
export default withMiddlewareAuthRequired();
export const config = {
matcher: '/dashboard/:path*',
};
// src/app/api/auth/[auth0]/route.ts
import { handleAuth, handleLogin } from '@auth0/nextjs-auth0';
export const GET = handleAuth({
login: handleLogin({
returnTo: '/dashboard',
authorizationParams: {
// Request specific scopes
scope: 'openid profile email',
// Offer specific social connections
connection: undefined, // Let user choose
},
}),
});
// In components — useUser hook from Auth0
'use client';
import { useUser } from '@auth0/nextjs-auth0/client';
export function UserMenu() {
const { user, error, isLoading } = useUser();
if (isLoading) return <Skeleton />;
if (error || !user) return <a href="/api/auth/login">Sign in</a>;
return (
<div>
<img src={user.picture!} alt={user.name!} />
<span>{user.name}</span>
<a href="/api/auth/logout">Sign out</a>
</div>
);
}
Token Refresh Pattern
Our access tokens expire in 15 minutes. Auto-refresh them
using the refresh token without requiring re-login.
// Axios interceptor that auto-refreshes on 401
import axios from 'axios';
const api = axios.create({ baseURL: '/api' });
let isRefreshing = false;
let refreshSubscribers: Array<(token: string) => void> = [];
api.interceptors.response.use(
response => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error);
}
if (isRefreshing) {
// Queue requests while refresh is in flight
return new Promise(resolve => {
refreshSubscribers.push((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(api(originalRequest));
});
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const { data } = await axios.post('/api/auth/refresh');
const newToken = data.accessToken;
// Notify all queued requests
refreshSubscribers.forEach(cb => cb(newToken));
refreshSubscribers = [];
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return api(originalRequest);
} catch (refreshError) {
// Refresh failed — redirect to login
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
);
For the base authentication implementation (sessions, password hashing, MFA), see the authentication guide. For the security testing that validates OAuth flows can’t be bypassed, see the security testing guide. The Claude Skills 360 bundle includes OAuth and SSO skill sets for Google, GitHub, Microsoft, and enterprise SAML. Start with the free tier to try OAuth flow generation.