OAuth Integration Security Guide

Share

To secure OAuth integrations, you need to: (1) always implement PKCE flow even for server-side applications, (2) validate state parameters to prevent CSRF attacks, (3) exchange authorization codes server-side only where client secrets are secure, (4) verify ID tokens using the provider's JWKS before trusting claims, and (5) explicitly configure redirect URIs in your OAuth provider. This blueprint covers secure OAuth patterns for any provider integration.

Setup Time2-3 hours

TL;DR

Always use PKCE (even for server-side apps), validate state parameters to prevent CSRF, exchange codes server-side only, and verify tokens before trusting their contents. Use established libraries instead of implementing OAuth from scratch.

PKCE Flow Implementation OAuth 2.0

lib/oauth.ts
import crypto from 'crypto'

function generateCodeVerifier(): string {
  return crypto.randomBytes(32).toString('base64url')
}

function generateCodeChallenge(verifier: string): string {
  return crypto.createHash('sha256').update(verifier).digest('base64url')
}

export async function initiateOAuth(provider: 'github' | 'google') {
  const state = crypto.randomBytes(16).toString('hex')
  const codeVerifier = generateCodeVerifier()
  const codeChallenge = generateCodeChallenge(codeVerifier)

  // Store in session/cookie (encrypted)
  await storeOAuthState({ state, codeVerifier, provider })

  const params = new URLSearchParams({
    client_id: process.env[`${provider.toUpperCase()}_CLIENT_ID`]!,
    redirect_uri: `${process.env.BASE_URL}/api/auth/callback/${provider}`,
    scope: 'openid profile email',
    response_type: 'code',
    state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  })

  return `https://provider.example.com/authorize?${params}`
}

Callback Handler

app/api/auth/callback/[provider]/route.ts
export async function GET(
  req: Request,
  { params }: { params: { provider: string } }
) {
  const url = new URL(req.url)
  const code = url.searchParams.get('code')
  const state = url.searchParams.get('state')
  const error = url.searchParams.get('error')

  if (error) {
    return Response.redirect('/login?error=oauth_failed')
  }

  // Retrieve stored state
  const storedState = await getOAuthState()

  // CRITICAL: Validate state to prevent CSRF
  if (!storedState || storedState.state !== state) {
    return Response.redirect('/login?error=invalid_state')
  }

  // Exchange code for tokens (server-side only!)
  const tokenResponse = await fetch('https://provider.example.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code!,
      redirect_uri: `${process.env.BASE_URL}/api/auth/callback/${params.provider}`,
      client_id: process.env.CLIENT_ID!,
      client_secret: process.env.CLIENT_SECRET!,
      code_verifier: storedState.codeVerifier,  // PKCE
    }),
  })

  const tokens = await tokenResponse.json()

  // Verify ID token before trusting it
  const user = await verifyIdToken(tokens.id_token)

  // Create session
  await createSession(user)

  return Response.redirect('/dashboard')
}

Token Verification OAuth 2.0

lib/verify-token.ts
import * as jose from 'jose'

export async function verifyIdToken(idToken: string) {
  // Fetch provider's public keys
  const JWKS = jose.createRemoteJWKSet(
    new URL('https://provider.example.com/.well-known/jwks.json')
  )

  try {
    const { payload } = await jose.jwtVerify(idToken, JWKS, {
      issuer: 'https://provider.example.com',
      audience: process.env.CLIENT_ID!,
    })

    // Verify required claims
    if (!payload.sub || !payload.email) {
      throw new Error('Missing required claims')
    }

    return {
      id: payload.sub,
      email: payload.email as string,
      name: payload.name as string,
    }
  } catch (error) {
    throw new Error('Invalid ID token')
  }
}

Never exchange codes client-side. The authorization code must be exchanged for tokens on your server, where the client secret is secure. PKCE adds protection but doesn't replace server-side exchange.

Security Checklist

Pre-Launch Checklist

PKCE implemented (code_challenge)

State parameter validated

Code exchange server-side only

ID tokens verified before use

Redirect URIs explicitly configured

Client secret stored securely

Auth0 Managed OAuth Clerk OAuth Integration NextAuth Self-Hosted OAuth

Check Your OAuth Implementation

Scan for OAuth vulnerabilities.

Start Free Scan
Security Blueprints

OAuth Integration Security Guide