Mastering OAuth2 Login in the Frontend: Full Flowchart and Common Pitfalls

This article walks through the complete OAuth2 Authorization Code flow with PKCE for front‑end applications, explains each step with code examples, highlights six frequent pitfalls such as redirect_uri mismatches, missing state validation, PKCE requirements, one‑time code usage, URL leakage, and insecure token storage, and provides a ready‑to‑use implementation template.

CodeNotes
CodeNotes
CodeNotes
Mastering OAuth2 Login in the Frontend: Full Flowchart and Common Pitfalls

0. Understand the OAuth2 Authorization Code Flow

// Redirect to the authorization page, user authorizes, redirects back, then get token, ...
// What actually happens in the middle?
window.location.href = `https://auth.example.com/authorize?
    response_type=code&
    client_id=${CLIENT_ID}&
    redirect_uri=${encodeURIComponent(REDIRECT_URI)}&
    scope=openid profile email&
    state=${generateState()}`;

The redirect, user consent, callback with code, token exchange, and user‑info request each can fail, often returning a single‑line error like invalid_request that gives no clue.

We will now explain the OAuth2 Authorization Code flow with PKCE and the typical pitfalls.

1. Front‑end initiates authorization (including PKCE)

// Generate random state (CSRF protection)
function generateState() {
    return crypto.randomUUID();
}

// Generate PKCE code_verifier and code_challenge
async function generatePkcePair() {
    const bytes = new Uint8Array(32);
    crypto.getRandomValues(bytes);
    const codeVerifier = btoa(String.fromCharCode(...bytes))
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=+$/, '');
    const digest = await crypto.subtle.digest(
        'SHA-256',
        new TextEncoder().encode(codeVerifier)
    );
    const hash = new Uint8Array(digest);
    const codeChallenge = btoa(String.fromCharCode(...hash))
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=+$/, '');
    return { codeVerifier, codeChallenge };
}

// Redirect to the authorization server
async function redirectToAuth() {
    const state = generateState();
    const { codeVerifier, codeChallenge } = await generatePkcePair();
    // Store for later verification
    sessionStorage.setItem('oauth_state', state);
    sessionStorage.setItem('pkce_code_verifier', codeVerifier);

    const params = new URLSearchParams({
        response_type: 'code',
        client_id: process.env.VITE_OAUTH_CLIENT_ID,
        redirect_uri: process.env.VITE_OAUTH_REDIRECT_URI,
        scope: 'openid profile email',
        state: state,
        code_challenge: codeChallenge,
        code_challenge_method: 'S256',
    });

    window.location.href = `${process.env.VITE_OAUTH_AUTH_URL}?${params.toString()}`;
}

2. User logs in and authorizes on the server

The server validates the credentials, asks the user to consent, then issues a temporary code and redirects back to the configured redirect_uri.

3. Callback page extracts the code and exchanges it for a token

// Run on page load
function handleCallback() {
    const params = new URLSearchParams(window.location.search);
    const code = params.get('code');
    const state = params.get('state');
    const error = params.get('error');

    if (error) {
        console.error('Authorization failed:', error);
        return;
    }

    const savedState = sessionStorage.getItem('oauth_state');
    if (state !== savedState) {
        console.error('State mismatch – possible CSRF attack');
        return;
    }

    if (!code) {
        console.error('No authorization code received');
        return;
    }

    // Remove code from URL to avoid leakage
    window.history.replaceState({}, '', window.location.pathname);

    // BFF mode – send code + verifier to backend
    const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
    exchangeCodeForToken(code, codeVerifier);
}

// Backend endpoint to exchange code for token
async function exchangeCodeForToken(code, codeVerifier) {
    const response = await fetch('/api/auth/token', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ code, codeVerifier }),
    });
    const data = await response.json();
    if (data.success) {
        // Login succeeded – store token
        localStorage.setItem('token', data.token);
        window.location.href = '/dashboard';
    }
}

4. Key Pitfalls in Front‑end Integration

Pitfall 1: redirect_uri must match exactly

// ❌ Configured: https://example.com/callback
// Code uses: http://localhost:3000/callback → redirect_uri mismatch!

// ✅ Keep them identical
// Server config: https://example.com/callback
// Front‑end code: https://example.com/callback

Pitfall 2: state validation is required to prevent CSRF

The random state generated before the redirect must be compared with the value returned on callback.

Pitfall 3: PKCE (S256) is mandatory for public clients

OAuth 2.1 makes PKCE required for the Authorization Code flow; without it the code can be replayed.

Pitfall 4: code can be exchanged only once

The code is single‑use; a second exchange returns invalid_grant.

Pitfall 5: code in URL can leak via history or Referer

// Immediately clear URL parameters after obtaining the code
window.history.replaceState({}, '', window.location.pathname);

Pitfall 6: storing token in localStorage is vulnerable to XSS

// ❌ Storing in localStorage allows XSS theft
localStorage.setItem('token', data.token);

// ✅ Prefer HttpOnly cookies (watch out for CSRF) or short‑lived sessionStorage

5. Complete Front‑end OAuth2 Template

// lib/oauth.ts
const CONFIG = {
    clientId: import.meta.env.VITE_OAUTH_CLIENT_ID,
    authUrl: import.meta.env.VITE_OAUTH_AUTH_URL,
    tokenUrl: import.meta.env.VITE_OAUTH_TOKEN_URL,
    redirectUri: import.meta.env.VITE_OAUTH_REDIRECT_URI,
    scope: 'openid profile email',
};

async function generatePkcePair() { /* same as above */ }

export async function loginWithOAuth() {
    const state = crypto.randomUUID();
    const { codeVerifier, codeChallenge } = await generatePkcePair();
    sessionStorage.setItem('oauth_state', state);
    sessionStorage.setItem('pkce_code_verifier', codeVerifier);
    const params = new URLSearchParams({
        response_type: 'code',
        client_id: CONFIG.clientId,
        redirect_uri: CONFIG.redirectUri,
        scope: CONFIG.scope,
        state,
        code_challenge: codeChallenge,
        code_challenge_method: 'S256',
    });
    window.location.href = `${CONFIG.authUrl}?${params}`;
}

export function handleOAuthCallback() {
    return new Promise((resolve, reject) => {
        const params = new URLSearchParams(window.location.search);
        const code = params.get('code');
        const state = params.get('state');
        const error = params.get('error');
        if (error) return reject(new Error(`OAuth error: ${error}`));
        const savedState = sessionStorage.getItem('oauth_state');
        if (state !== savedState) return reject(new Error('State mismatch'));
        if (!code) return reject(new Error('No code received'));
        const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
        if (!codeVerifier) return reject(new Error('Missing PKCE code_verifier'));
        window.history.replaceState({}, '', window.location.pathname);
        resolve({ code, codeVerifier });
    });
}

6. OAuth2 Authorization Code Flow Diagram

┌───────────┐      ┌─────────────┐      ┌──────────────┐
│   Frontend│      │  Backend(BFF)│      │ Authorization│
│           │      │             │      │   Server    │
└─────┬─────┘      └──────┬──────┘      └──────┬───────┘
      │                 │                 │
      │ 1. Redirect to auth page (with code_challenge)   │
      │──────────────────────────────────────────────▶│
      │                 │                 │ User logs in + consents
      │ 2. Redirect back with code + state            │
      │◀──────────────────────────────────────────────│
      │ 3. Verify state, extract code/code_verifier   │
      │ 4. Send to backend                            │
      │──────────────────────────────────────────────▶│
      │                 │ 5. Request token (code + verifier)   │
      │                 │──────────────────────────────────────▶│
      │                 │ 6. Return token                      │
      │                 │◀──────────────────────────────────────│
      │ 7. Login success – session established        │

Summary

OAuth2 Authorization Code flow with PKCE: redirect → user consent → callback → state verification → exchange code + verifier for token.

redirect_uri must match the server configuration exactly (protocol, domain, port, path).

state is essential for CSRF protection and must be validated.

PKCE (S256) is required for public clients (SPA/mobile).

code is single‑use; reuse triggers invalid_grant.

Immediately clear the URL parameters after obtaining the code to avoid leakage.

Store tokens securely: prefer HttpOnly cookies; if stored in the front‑end, use short‑lived sessionStorage and guard against XSS.

The front‑end only handles redirection, code extraction, and token exchange; the client secret stays on the back‑end.

One‑liner: OAuth2 login isn’t just “code flow” – remember “code + PKCE”; the front‑end handles redirects and callback checks, the back‑end safely exchanges the token, and guarding redirect_uri, state, and PKCE avoids most pitfalls.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

frontendsecuritySPAOAuth2Authorization CodeOAuth FlowPKCE
CodeNotes
Written by

CodeNotes

Discuss code and AI, and document daily life and personal growth.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.