Securing Single‑Page Apps with OAuth 2.0 Authorization Code Flow and PKCE
Learn how to protect SPA backends by using OAuth 2.0 Authorization Code Flow with PKCE, storing tokens in httpOnly cookies and avoiding implicit‑flow leaks.
27 Jun 2026, 18:00 UTC

Problem: Token leakage in SPA implicit flow
When a single‑page application (SPA) uses the OAuth 2.0 implicit flow, the access token is returned in the URL fragment and remains visible to JavaScript. Any successful cross‑site scripting (XSS) attack can read the token from window.location.hash or localStorage and send it to an attacker. This exposes APIs to token theft and makes it hard to rotate tokens without user interaction.
Solution: Authorization Code Flow with PKCE
The Authorization Code Flow with Proof Key for Code Exchange (PKCE) removes the need to expose the access token in the browser. Instead, the SPA receives only an authorization code, which it exchanges for tokens at a backend token endpoint. The code verifier/challenge pair prevents an attacker who intercepts the authorization code from completing the exchange.
How PKCE works
- The SPA generates a random
code_verifier(43‑128 characters) and derives acode_challengeby Base64URL‑encoding the SHA‑256 hash of the verifier. - The SPA redirects to the authorization endpoint with parameters
response_type=code,client_id,redirect_uri,code_challenge, andcode_challenge_method=S256. - After the user authenticates, the authorization server redirects back to the
redirect_uriwith anauthorization_codequery parameter. - The SPA sends the code to its backend, which posts to the token endpoint with
grant_type=authorization_code, the received code,redirect_uri,client_id, and the originalcode_verifier. - The token endpoint validates that the verifier matches the earlier challenge; if it does, it returns an
access_token(and optionally arefresh_token). The backend stores the tokens in anhttpOnly,Secure,SameSite=Strictcookie and returns a session identifier to the SPA.
Worked example: React SPA with Node/Express backend
Below is a minimal illustration that shows where each step lives. The code is not tested; it is meant to demonstrate the flow.
// 1. SPA: generate verifier and challenge (using Web Crypto)
async function pkcePair() {
const verifier = base64urlencode(await crypto.getRandomValues(new Uint8Array(32)));
const challenge = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
return { verifier, challenge: base64urlencode(new Uint8Array(challenge)) };
}
// 2. SPA: start login
async function startLogin() {
const { verifier, challenge } = await pkcePair();
sessionStorage.setItem('pkce_verifier', verifier); // temporary, cleared after callback
const url = new URL('https://auth.example.com/authorize');
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', 'my-spa-client');
url.searchParams.set('redirect_uri', 'https://spa.example.com/callback');
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
window.location.href = url.toString();
}
// 3. SPA: callback handler (simplified)
function handleCallback() {
const code = new URLSearchParams(window.location.search).get('code');
const verifier = sessionStorage.getItem('pkce_verifier');
sessionStorage.removeItem('pkce_verifier');
// Send code+verifier to backend
fetch('https://api.example.com/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, verifier })
})
.then(r => r.json())
.then(data => {
// backend sets httpOnly cookie; SPA just gets a session id
if (data.sessionId) {
document.cookie = `session=${data.sessionId}; SameSite=Strict; Path=/`;
// proceed to app
}
});
}
// 4. Backend (Express) token exchange
app.post('/auth/token', async (req, res) => {
const { code, verifier } = req.body;
const tokenResponse = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: 'https://spa.example.com/callback',
client_id: 'my-spa-client',
code_verifier: verifier
})
});
const tokenData = await tokenResponse.json();
if (tokenData.access_token) {
// Store tokens in httpOnly cookie
res.cookie('access_token', tokenData.access_token, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 5*60 });
if (tokenData.refresh_token) {
res.cookie('refresh_token', tokenData.refresh_token, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 24*60*60 });
}
res.json({ sessionId: crypto.randomUUID() });
} else {
res.status(400).json({ error: tokenData.error });
}
});
Trade‑off and limitation
Adding PKCE moves token handling to a backend, which means the SPA can no longer be completely static; it needs a server‑side component capable of storing client secrets securely and performing the token exchange. This introduces extra network latency (one additional round‑trip to the token endpoint) and requires careful cookie configuration to avoid CSRF. If the backend is compromised, the refresh token could be abused, so short‑lived access tokens and refresh‑token rotation are recommended.
Practical verification steps
- Inspect the network request to the authorization endpoint and confirm that
code_challengeandcode_challenge_method=S256are present. - On the callback, verify that the backend receives both
codeandcode_verifierand that the token endpoint returnsinvalid_grantwhen the verifier is altered. - Check that the Set‑Cookie header for the access token includes
HttpOnly,Secure, andSameSite=Strictattributes.
Actionable closing
To adopt this pattern in your SPA:
- Add a PKCE verifier/challenge generation step to your login flow.
- Ensure your authorization server is configured to accept
code_challengeandcode_challenge_method=S256. - Implement a lightweight backend endpoint that exchanges the authorization code for tokens using the verifier.
- Store the returned tokens in httpOnly, Secure, SameSite cookies and never expose them to JavaScript.
- Set short access‑token lifetimes (e.g., 5‑15 minutes) and rotate refresh tokens on each use.
- Run the verification steps above and, if possible, execute an OAuth 2.0 PKCE test suite (such as the one provided by
oauth2-proxy) to confirm RFC 7636 compliance.
By moving the token exchange off the client and binding it with a verifier, you eliminate the most common leakage vectors while keeping the user experience smooth.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.