Hand-Rolling OAuth 2.0 Authorization Code + PKCE in a React SPA
Build a React login flow against Auth0 using Authorization Code + PKCE, with no SDK doing the work for you and no client secret anywhere in the browser.
What you'll build
A React SPA that logs users in against a real OAuth 2.0 / OIDC provider (Auth0) using the Authorization Code flow with PKCE, no client secret, no implicit grant, no third-party SDK doing the work for you. By the end you'll have a working login button, a callback handler that exchanges a code for tokens, and a home screen that reads the logged-in user's profile from the /userinfo endpoint.
Prerequisites
- Node.js 18+ and npm 9+
- A free Auth0 account (auth0.com). Any OIDC-compliant provider works, but the exact settings below assume Auth0.
- Basic familiarity with React hooks and
react-router-domv6 - A terminal and a browser with dev tools open (you'll want to watch the network tab)
- This whole flow depends on
crypto.subtle, which only exists in a secure context.http://localhostcounts as secure, but if you deploy anywhere else, you need HTTPS or the PKCE code silently fails to load.
Step 1: Register a SPA application
In the Auth0 dashboard, go to Applications > Create Application, name it, and pick Single Page Web Applications. This matters: Auth0 treats SPA apps as public clients, meaning no client secret is issued, and the token endpoint accepts PKCE-based requests with CORS enabled for browser origins. Pick "Regular Web Application" by mistake and the token exchange fails with a CORS error later, because that application type expects a confidential client authenticating with a secret from a server, not a browser.
In Settings, set:
- Allowed Callback URLs:
http://localhost:5173/callback - Allowed Logout URLs:
http://localhost:5173 - Allowed Web Origins:
http://localhost:5173
Note the Domain and Client ID at the top of the settings page. You'll need both.
Step 2: Scaffold the app
npm create vite@latest oauth-pkce-demo -- --template react
cd oauth-pkce-demo
npm install
npm install react-router-dom
Create a .env file at the project root:
VITE_AUTH0_DOMAIN=dev-xxxxxxx.us.auth0.com
VITE_AUTH0_CLIENT_ID=your_client_id_here
VITE_REDIRECT_URI=http://localhost:5173/callback
These aren't secrets. The client ID for a public client is meant to be visible in the browser, and there's no client secret in this flow at all, which is the whole point of PKCE.
Step 3: Generate the PKCE verifier and challenge
Create src/pkce.js. This uses the Web Crypto API, available in every modern browser, no polyfill needed.
function base64UrlEncode(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
export function generateRandomString(byteLength = 64) {
const array = new Uint8Array(byteLength);
crypto.getRandomValues(array);
return base64UrlEncode(array.buffer);
}
export async function generateCodeChallenge(verifier) {
const data = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(digest);
}
64 random bytes encode down to roughly 86 base64url characters, comfortably inside the 43-128 character range RFC 7636 requires for a code_verifier. The verifier is a random string only your browser tab ever knows. The challenge is its SHA-256 hash, sent up front. The authorization server holds onto the challenge and later demands the raw verifier at token exchange time to prove the same client that started the flow is the one finishing it. That's what makes a stolen authorization code useless if it's intercepted mid-redirect.
Step 4: Build the login redirect
Create src/auth.js:
import { generateRandomString, generateCodeChallenge } from './pkce';
const DOMAIN = import.meta.env.VITE_AUTH0_DOMAIN;
const CLIENT_ID = import.meta.env.VITE_AUTH0_CLIENT_ID;
const REDIRECT_URI = import.meta.env.VITE_REDIRECT_URI;
export async function login() {
const codeVerifier = generateRandomString(64);
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = generateRandomString(32);
sessionStorage.setItem('pkce_code_verifier', codeVerifier);
sessionStorage.setItem('oauth_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile email',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
});
window.location.assign(`https://${DOMAIN}/authorize?${params}`);
}
export function logout() {
const params = new URLSearchParams({
client_id: CLIENT_ID,
returnTo: window.location.origin,
});
window.location.assign(`https://${DOMAIN}/v2/logout?${params}`);
}
Note response_type: 'code'. Never use token or id_token here (the implicit flow); it puts access tokens directly in the URL fragment where they leak into browser history and referrer headers. Authorization Code + PKCE is the flow current OAuth guidance recommends for browser-based apps.
One subtlety worth flagging now: because we don't send an audience parameter, Auth0 returns an opaque access token good only for calling /userinfo, not a JWT you can decode client-side. If you need a JWT access token for your own API, you add audience to this request (see Next Steps).
Step 5: Handle the callback
Add the token exchange function, still in src/auth.js. It now takes the verifier as a parameter instead of reading storage itself, so the caller controls exactly when that value gets read and cleared:
export async function exchangeCodeForTokens(code, codeVerifier) {
const response = await fetch(`https://${DOMAIN}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
code_verifier: codeVerifier,
code,
redirect_uri: REDIRECT_URI,
}),
});
if (!response.ok) throw new Error(`Token exchange failed: ${response.status}`);
return response.json(); // { access_token, id_token, expires_in, token_type }
}
Now the callback route, src/Callback.jsx:
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { exchangeCodeForTokens } from './auth';
import { useAuth } from './AuthContext';
export default function Callback() {
const navigate = useNavigate();
const { setTokens } = useAuth();
const [error, setError] = useState(null);
const ranOnce = useRef(false);
useEffect(() => {
if (ranOnce.current) return; // guard against StrictMode double-invoke
ranOnce.current = true;
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const returnedState = params.get('state');
const expectedState = sessionStorage.getItem('oauth_state');
const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
// Single-use values, clear them now regardless of outcome
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('pkce_code_verifier');
if (params.get('error')) {
setError(params.get('error_description') || 'Login failed');
return;
}
if (!code || !codeVerifier || returnedState !== expectedState) {
setError('State mismatch, possible CSRF attempt');
return;
}
exchangeCodeForTokens(code, codeVerifier)
.then((tokens) => {
setTokens(tokens);
navigate('/', { replace: true });
})
.catch((err) => setError(err.message));
}, [navigate, setTokens]);
if (error) return <p>Login error: {error}</p>;
return <p>Signing you in…</p>;
}
Two details matter here. First, ranOnce: React 18 StrictMode invokes effects twice in development, and authorization codes are single-use. Without the guard you'll get invalid_grant on the second call, every time, in dev only. Use useRef, not useState, since a ref survives across renders without triggering one, exactly what a one-shot mutable flag needs. Second, clearing sessionStorage immediately (before the exchange even runs) rather than only after success means a failed or abandoned login can't have its verifier or state reused on a retry.
Calling navigate('/', { replace: true }) instead of manually mutating window.history keeps React Router's internal state in sync with the actual URL, no need to hand-roll history manipulation alongside it.
Step 6: Wire up context, routes, and a login button
Keep tokens in memory (React context), not localStorage. Local storage is readable by any script on the page, so any XSS vulnerability anywhere in your dependency tree turns into a full account takeover. In-memory storage means a page refresh logs the user out unless you add silent renewal later. That's a real tradeoff, not a bug.
src/AuthContext.jsx:
import { createContext, useContext, useState } from 'react';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [tokens, setTokens] = useState(null);
return (
<AuthContext.Provider value={{ tokens, setTokens }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
src/Home.jsx is your login button and profile display in one:
import { useEffect, useState } from 'react';
import { login, logout } from './auth';
import { useAuth } from './AuthContext';
const DOMAIN = import.meta.env.VITE_AUTH0_DOMAIN;
export default function Home() {
const { tokens } = useAuth();
const [profile, setProfile] = useState(null);
useEffect(() => {
if (!tokens) return;
let cancelled = false;
fetch(`https://${DOMAIN}/userinfo`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
})
.then((res) => {
if (!res.ok) throw new Error(`userinfo failed: ${res.status}`);
return res.json();
})
.then((data) => { if (!cancelled) setProfile(data); })
.catch(() => { if (!cancelled) setProfile(null); });
return () => { cancelled = true; };
}, [tokens]);
if (!tokens) return <button onClick={login}>Log in</button>;
return (
<div>
<p>Welcome, {profile?.name ?? '…'}</p>
<button onClick={logout}>Log out</button>
</div>
);
}
The cancelled flag stops a slow response from calling setProfile after the component's unmounted, and the res.ok check means an expired or malformed token shows "…" instead of crashing on undefined.name.
src/App.jsx maps routes:
import { Routes, Route } from 'react-router-dom';
import Home from './Home';
import Callback from './Callback';
export default function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/callback" element={<Callback />} />
</Routes>
);
}
And src/main.jsx wraps everything. BrowserRouter needs to sit above anything using useNavigate, which is why it wraps AuthProvider and App:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import { AuthProvider } from './AuthContext';
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</StrictMode>
);
If you add more routes that need auth, wrap them instead of repeating the tokens check everywhere:
import { Navigate } from 'react-router-dom';
import { useAuth } from './AuthContext';
function ProtectedRoute({ children }) {
const { tokens } = useAuth();
if (!tokens) return <Navigate to="/" replace />;
return children;
}
Verify it works
Run npm run dev, open http://localhost:5173, click Log in. You should land on Auth0's hosted login page, not a blank redirect. After authenticating, you're bounced to /callback and then back to /, where you should see "Welcome, <your name>" pulled straight from the /userinfo endpoint. Open the network tab and confirm the POST to /oauth/token returns 200 with a JSON body containing access_token, id_token, and expires_in.
If that profile renders, the whole chain, redirect, PKCE verification, code exchange, worked end to end.
Troubleshooting
invalid_grant: Authorization code already used: almost always the StrictMode double-effect issue from Step 5, or the browser back button replaying an old callback URL. Confirm theranOnceguard is in place and never let users bookmark/callback.- CORS error on the
/oauth/tokenPOST: your Auth0 application type isn't set to "Single Page Application." Regular Web Applications authenticate with a client secret and don't get CORS enabled on the token endpoint, since they're built for confidential, server-side clients. Callback URL mismatchon the Auth0 login page: theredirect_uriyour app sends must exactly match an entry in Allowed Callback URLs, including protocol, port, and trailing slash.- State mismatch on first try: usually a stale value from a previous failed attempt. Clear
sessionStorageand retry; if it persists, check you're not opening the login link in a new tab.sessionStoragedoesn't share across tabs, so the verifier and state won't be there when the callback fires.
Next steps
Add silent token renewal with rotating refresh tokens (offline_access scope, Refresh Token Rotation enabled in your Auth0 app settings) so users don't get logged out on refresh. If you need to call your own backend, add an audience parameter to request a JWT access token for a custom API, and validate that JWT's signature server-side against the provider's JWKS endpoint, never trust a token you haven't verified. Once you understand every step here, it's fair to reach for @auth0/auth0-spa-js or oidc-client-ts in production. They handle token refresh, clock skew, and edge cases this tutorial deliberately skipped to keep the flow visible.
Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.
Discussion 0
No comments yet
Be the first to weigh in.