OAuth 2.0 and OpenID Connect (OIDC)

Vibe Prompt

"Implement OAuth 2.0 Authorization Code Flow for me: Frontend redirects to Google Sign-In, Backend receives callback and exchanges code for Tokens."

The Four Grant Flows Overview

| Flow | Use Case | Status | |------|----------|--------| | Authorization Code | Server-side Apps (Backend exists) | Recommended Standard | | Authorization Code + PKCE | SPAs, Mobile Apps, Native Apps | Modern Standard | | Client Credentials | Machine-to-Machine (M2M) | Recommended for Services | | Device Authorization Grant | TVs, IoT, CLI tools (No Browser) | Recommended for Constrained Devices | | Implicit Flow | Legacy SPAs | Deprecated / Do Not Use | | Resource Owner Password Credentials | Highly Trusted Legacy Apps | Deprecated / Do Not Use |


Deep Dive: Authorization Code Flow (with PKCE)

This is the gold standard for almost all modern user-facing applications. It separates the Authorization step (User consent on Provider UI) from the Token step (Secure Server-to-Server exchange).

The Sequence Diagram (Step-by-Step)

sequenceDiagram
    participant User
    participant Browser (Frontend)
    participant Backend Server
    participant Auth Server (Google, GitHub, etc.)

    User->>Browser: Clicks "Login with Google"
    Browser->>Auth Server: 1. Redirect to /authorize?response_type=code&client_id=...&redirect_uri=...&scope=openid%20profile%20email&state=...&code_challenge=...&code_challenge_method=S256
    Auth Server->>User: 2. Shows Consent Screen ("App wants access to your email")
    User->>Auth Server: 3. Grants Consent
    Auth Server->>Browser: 4. Redirects to redirect_uri?code=AUTH_CODE&state=...
    Browser->>Backend Server: 5. Sends Authorization Code (via query param or POST body)
    Backend Server->>Auth Server: 6. POST /token (code + client_id + client_secret + code_verifier)
    Auth Server->>Backend Server: 7. Returns JSON: { access_token, id_token, refresh_token, expires_in }
    Backend Server->>Browser: 8. Sets Secure HttpOnly Session Cookie / Returns Custom JWT
    Browser->>User: 9. Logged In UI

Detailed Step Breakdown

1. Initiation & PKCE Generation (Frontend)

What: Before redirecting, the frontend generates a cryptographically random code_verifier (43-128 chars) and derives a code_challenge (SHA256 hash, Base64URL encoded). Why: Proof Key for Code Exchange (PKCE) mitigates Authorization Code Interception attacks. Even if a malicious app intercepts the code via a custom URL scheme hijack (mobile) or referrer header leak (web), they cannot exchange it for tokens without the code_verifier. How (Vibe Coding):

"Generate a PKCE code_verifier and code_challenge (S256) in the React useEffect hook. Store code_verifier in sessionStorage. Redirect to https://accounts.google.com/o/oauth2/v2/auth with code_challenge and state."

2. Authorization Request (Browser -> Auth Server)

Parameters:

  • response_type=code: We want an Authorization Code.
  • client_id: Public identifier of your app.
  • redirect_uri: Must exactly match pre-registered URI (prevents open redirects).
  • scope=openid profile email: OIDC Scopes. openid is mandatory for OIDC. profile/email request user info.
  • state: CSRF Protection. Random string generated per request, validated on callback.
  • code_challenge / code_challenge_method=S256: PKCE parameters.

3. User Consent (Auth Server -> User)

The Authorization Server authenticates the user (password, 2FA, passkeys) and presents the Consent Screen. Business Value: This is where Trust is established. A clear, branded consent screen increases conversion rates. Requesting minimal scopes (email only vs profile email phone address) builds user trust and reduces friction.

4. Callback with Authorization Code (Auth Server -> Frontend)

The Auth Server redirects back to your redirect_uri with ?code=...&state=.... Security Check: Frontend must validate state matches the one stored in sessionStorage before proceeding. If mismatch -> Potential CSRF Attack -> Abort.

5. Token Exchange (Backend -> Auth Server) - The Critical Secure Step

What: Backend sends code, client_id, client_secret, redirect_uri, grant_type=authorization_code, and code_verifier. Why Backend? The client_secret proves the identity of the Client Application to the Auth Server. Never expose client_secret in the Frontend (SPA/Mobile). If you don't have a backend, you must use PKCE (which you are) but understand the token ends up in the browser storage (less secure than HttpOnly cookies). How (Vibe Coding - Node/Express Example):

"Create a POST /api/auth/callback route. Extract code from query. Call fetch('https://oauth2.googleapis.com/token', { method: 'POST', body: new URLSearchParams({ code, client_id: process.env.GOOGLE_CLIENT_ID, client_secret: process.env.GOOGLE_CLIENT_SECRET, redirect_uri: process.env.REDIRECT_URI, grant_type: 'authorization_code', code_verifier: storedVerifier }) }). Parse JSON response."

6. Token Response (Auth Server -> Backend)

{
  "access_token": "ya29.a0AfH6SMC...",  // Opaque string (usually) or JWT. Used for API calls.
  "id_token": "eyJhbGciOiJSUzI1NiIs...", // **JWT (OIDC)**. Contains user identity (sub, email, name).
  "refresh_token": "1//0g...",           // Long-lived. Used to get new Access Tokens. **Store Securely (Encrypted DB).**
  "expires_in": 3599,                    // Access Token lifetime in seconds (~1 hour).
  "token_type": "Bearer",
  "scope": "openid profile email"
}

7. Session Establishment (Backend -> Frontend)

Best Practice: Do not send access_token or refresh_token to the Frontend in the response body if using a traditional Backend. Instead:

  1. Store refresh_token (encrypted) + access_token (optional, short-lived) in DB keyed by User ID.
  2. Create a Session ID (cryptographically random).
  3. Set HttpOnly, Secure, SameSite=Lax (or Strict) Cookie: Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000.
  4. Frontend now relies on Cookie for subsequent requests. Backend middleware reads cookie, looks up session, attaches access_token to outgoing API calls to Google/Resource Servers.

Verifying the ID Token (The OIDC Core)

The id_token is a JSON Web Token (JWT) signed by the Provider (Google). You must verify it before trusting the user identity.

What needs verification?

  1. Signature: Verified against Provider's Public Keys (JWKS).
  2. Issuer (iss): Must match https://accounts.google.com.
  3. Audience (aud): Must match your CLIENT_ID. Prevents token replay from other apps.
  4. Expiration (exp) / Issued At (iat): Current time must be within window.
  5. Nonce (Optional but Recommended): If you sent nonce in auth request, verify it matches here (prevents replay attacks).

Production-Grade Verification Code (Python/python-jose or authlib)

import requests
from jose import jwt, JWTError
from functools import lru_cache
from typing import Dict, Any

GOOGLE_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs"
GOOGLE_ISSUER = "https://accounts.google.com"
CLIENT_ID = "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com"

# 1. Cache JWKS keys (Rotate keys happen rarely, cache for 24h or use cache-control headers)
@lru_cache(maxsize=1)
def get_jwks() -> Dict[str, Any]:
    response = requests.get(GOOGLE_JWKS_URL, timeout=5)
    response.raise_for_status()
    return response.json()

def get_signing_key(kid: str) -> Dict[str, Any]:
    jwks = get_jwks()
    for key in jwks.get("keys", []):
        if key.get("kid") == kid:
            return key
    # Key not found -> Force refresh cache and retry once
    get_jwks.cache_clear()
    jwks = get_jwks()
    for key in jwks.get("keys", []):
        if key.get("kid") == kid:
            return key
    raise JWTError(f"Unable to find signing key for kid: {kid}")

def verify_google_id_token(id_token: str) -> Dict[str, Any]:
    """
    Verifies Google ID Token signature and standard claims.
    Returns the decoded claims (payload) if valid.
    Raises JWTError if invalid.
    """
    # Get Key ID from header (unverified)
    try:
        header = jwt.get_unverified_header(id_token)
    except JWTError as e:
        raise JWTError(f"Invalid token header: {e}")

    kid = header.get("kid")
    if not kid:
        raise JWTError("Missing 'kid' in token header")

    # Construct Public Key from JWK
    signing_key = get_signing_key(kid)
    # python-jose handles JWK -> Key conversion automatically if passed dict
    public_key = jwt.algorithms.RSAAlgorithm.from_jwk(signing_key)

    # Decode and Verify
    # options: verify_signature, verify_exp, verify_iat, verify_aud, verify_iss are True by default
    claims = jwt.decode(
        id_token,
        public_key,
        algorithms=["RS256"],
        audience=CLIENT_ID,
        issuer=GOOGLE_ISSUER,
        # access_token=access_token, # Optional: verify 'at_hash' if hybrid flow
    )

    # Additional Business Logic Checks
    # 1. Email Verified?
    if not claims.get("email_verified"):
        raise JWTError("Email not verified by Provider")

    # 2. Hosted Domain (G Suite/Workspace) Restriction?
    # if claims.get("hd") != "yourcompany.com": raise JWTError("Domain not allowed")

    return claims

# Usage
try:
    user_claims = verify_google_id_token(id_token_from_callback)
    user_id = user_claims["sub"]        # Stable, unique identifier (Primary Key)
    email = user_claims["email"]
    name = user_claims.get("name")
    picture = user_claims.get("picture")
    print(f"Authenticated User: {user_id} ({email})")
except JWTError as e:
    print(f"Token Verification Failed: {e}")
    # Return 401 Unauthorized

Simplifying Auth: Managed Services (Supabase, Clerk, Auth0, Firebase)

Why reinvent the wheel? Implementing the flows above correctly (PKCE, State, Nonce, JWKS Caching, Refresh Token Rotation, Token Revocation, Session Management, MFA, Passwordless, Social Providers config) takes weeks of engineering time and introduces massive security surface area.

Business Case for Managed Auth (Buy vs Build)

| Factor | Build In-House | Managed Service (Supabase/Clerk/Auth0) | | :--- | :--- | :--- | | Time to Market | 4-8 Weeks (Senior Eng) | Hours (SDK + Config) | | Security Liability | You (Breaches = Lawsuits) | Vendor (SOC2, ISO27001, Dedicated Sec Teams) | | Maintenance | Ongoing (Spec changes, Browser cookie policies) | Zero (Vendor handles Chrome 3PC deprecation, etc.) | | Advanced Features | MFA, Passkeys, Org/Teams, RBAC, Audit Logs | Built-in | | Cost | Engineering Salary ($150k+/yr) | Free Tier -> $25-$100/mo (Scales with MAU) | | Vendor Lock-in | None | Moderate (Standard OIDC allows migration) |

Supabase Auth Example (The "Vibe Coding" Way)

"Add Supabase JS client. Call supabase.auth.signInWithOAuth({ provider: 'google' }). Supabase handles PKCE, State, Callback, Session Cookie (HttpOnly), JWT issuance, and Row Level Security (RLS) integration automatically. Protect Next.js routes with supabase.auth.getUser() in Server Components or Middleware."

Code:

// lib/supabase/server.ts (Next.js Server Component/Action)
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies()
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() { return cookieStore.getAll() },
        setAll(cookiesToSet) {
          try { cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options)) } catch {}
        },
      },
    }
  )
}

// app/actions/login.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'

export async function signInWithGoogle() {
  const supabase = await createClient()
  const { data, error } = await supabase.auth.signInWithOAuth({
    provider: 'google',
    options: { redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback` }
  })
  if (data.url) redirect(data.url)
}

This replaces 500+ lines of custom OAuth code with 20 lines of battle-tested library code.


Key Concepts Summary: OAuth 2.0 vs OIDC

Grant Types Decision Matrix

| Grant Type | Actor | Client Type | Secret Safe? | User Interaction | Primary Use Case | | :--- | :--- | :--- | :--- | :--- | :--- | | Auth Code + PKCE | User | SPA / Mobile / Native | No | Yes (Browser) | Modern Standard for User Apps | | Auth Code | User | Server-Side (Next.js, Rails, Java) | Yes | Yes (Browser) | Traditional Web Apps | | Client Credentials | Machine | Backend Service / Daemon | Yes | No | M2M: Service A calls Service B API | | Device Code | User | TV / CLI / IoT | No | Yes (Secondary Device) | Input-Constrained Devices | | Refresh Token | User/Machine | Any | Yes (Backend) / No (SPA) | No (Background) | Long-lived Sessions / Offline Access |

OAuth 2.0 vs OpenID Connect (OIDC)

| Dimension | OAuth 2.0 (RFC 6749) | OpenID Connect (OIDC) | | :--- | :--- | :--- | | Core Purpose | Delegated Authorization ("App accesses User's Data on Resource Server") | Federated Authentication ("App knows Who the User Is") | | Primary Artifact | Access Token (Opaque or JWT) | ID Token (Standardized JWT) + Access Token | | Token Audience | Resource Server (API) | Client Application (Relying Party) | | Standard Scopes | api.read, files.write, custom | openid (required), profile, email, address, phone | | User Info Endpoint | Not Standardized | Standardized (/userinfo returns claims) | | Discovery | Not Standardized | Standardized (.well-known/openid-configuration) | | Session Mgmt | None | Session Management Spec (Logout, RP-Initiated Logout) |

Mental Model: OAuth 2.0 is the Pipe (Plumbing). OIDC is the ID Card flowing through the pipe. You always use OAuth 2.0 flows to get an OIDC ID Token.


Common Misconceptions & Security Pitfalls

| Misconception | Reality | Risk if Ignored | | :--- | :--- | :--- | | "OAuth is Login" | OAuth = Authorization. **OIDC =

Member Exclusive Free Tutorial

This chapter is free exclusive content for registered members! Please login or register to unlock immediately.

Login / Register Now