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.
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/callbackPitfall 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 sessionStorage5. 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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
