Web Authentication with OAuth 2.0 and OpenID Connect
Implement secure authentication using OAuth 2.0 and OIDC: authorization code flow with PKCE, ID tokens, scopes, token management, and common security pitfalls.
What you'll learn
- ✓How OAuth 2.0 and OpenID Connect work together for authentication
- ✓Implementing the authorization code flow with PKCE
- ✓Understanding ID tokens, access tokens, and refresh tokens
- ✓Managing token lifecycle: storage, refresh, and revocation
- ✓Avoiding common OAuth security mistakes
Prerequisites
None — this post is self-contained.
OAuth 2.0 is an authorization framework. OpenID Connect (OIDC) is an authentication layer built on top of it. Together, they let your application verify who a user is (authentication) and what they are allowed to access (authorization) without ever handling their password. This guide covers how to implement both correctly, from the authorization code flow to token management in production.
OAuth 2.0 vs OpenID Connect
OAuth 2.0 was designed to grant third-party applications limited access to a user’s resources. It answers the question “is this app allowed to access this data?” but it does not answer “who is this user?” The access token OAuth 2.0 issues is opaque to the client.
OIDC extends OAuth 2.0 by adding an ID token, a signed JWT that contains user identity claims like name, email, and a unique subject identifier. When your application needs to know who the user is, you need OIDC.
In practice, most “Login with Google/GitHub/Microsoft” flows use both: OIDC for authentication (getting the ID token) and OAuth 2.0 for authorization (getting an access token to call APIs).
The Authorization Code Flow with PKCE
The authorization code flow with PKCE (Proof Key for Code Exchange) is the recommended flow for web applications. It works for both server-rendered apps and single-page applications.
Step 1: Generate the Code Verifier and Challenge
Before redirecting to the authorization server, generate a random code verifier and its SHA-256 hash (the challenge):
// Generate PKCE parameters
function generatePKCE() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const verifier = base64URLEncode(array);
return crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
.then(hash => ({
verifier,
challenge: base64URLEncode(new Uint8Array(hash)),
}));
}
function base64URLEncode(buffer) {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
Step 2: Redirect to the Authorization Server
Build the authorization URL with the required parameters:
async function startLogin() {
const { verifier, challenge } = await generatePKCE();
// Store verifier for step 4
sessionStorage.setItem('pkce_verifier', verifier);
const params = new URLSearchParams({
response_type: 'code',
client_id: 'your-client-id',
redirect_uri: 'https://app.example.com/callback',
scope: 'openid profile email',
state: crypto.randomUUID(),
code_challenge: challenge,
code_challenge_method: 'S256',
});
// Store state for CSRF protection
sessionStorage.setItem('oauth_state', params.get('state'));
window.location.href =
`https://auth.example.com/authorize?${params}`;
}
The state parameter prevents CSRF attacks. The scope includes openid to request an ID token via OIDC.
Step 3: Handle the Callback
The authorization server redirects back to your app with an authorization code:
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
// Verify state matches
if (state !== sessionStorage.getItem('oauth_state')) {
throw new Error('State mismatch - possible CSRF attack');
}
const verifier = sessionStorage.getItem('pkce_verifier');
// Clean up
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('pkce_verifier');
// Exchange code for tokens
const tokens = await exchangeCode(code, verifier);
return tokens;
}
Step 4: Exchange the Code for Tokens
Send the authorization code and PKCE verifier to the token endpoint:
async function exchangeCode(code, verifier) {
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: 'your-client-id',
code,
redirect_uri: 'https://app.example.com/callback',
code_verifier: verifier,
}),
});
if (!response.ok) {
throw new Error('Token exchange failed');
}
return response.json();
// Returns: { access_token, id_token, refresh_token, expires_in }
}
Understanding the Tokens
The token endpoint returns up to three tokens:
ID Token: A signed JWT containing user identity claims. Decode it to get user information:
function parseIdToken(idToken) {
const payload = idToken.split('.')[1];
const decoded = JSON.parse(atob(payload));
// Validate required claims
if (decoded.iss !== 'https://auth.example.com') {
throw new Error('Invalid issuer');
}
if (decoded.aud !== 'your-client-id') {
throw new Error('Invalid audience');
}
if (decoded.exp < Date.now() / 1000) {
throw new Error('Token expired');
}
return {
userId: decoded.sub,
email: decoded.email,
name: decoded.name,
};
}
Always validate the iss (issuer), aud (audience), and exp (expiration) claims. In production, also verify the JWT signature using the provider’s public keys from their JWKS endpoint.
Access Token: Used to call protected APIs. Treat it as opaque, even if it looks like a JWT. Its contents are meant for the resource server, not your application.
Refresh Token: A long-lived token used to obtain new access tokens without re-authenticating the user. Store it securely.
Token Storage and Lifecycle
Where you store tokens determines your security posture:
| Storage | XSS Risk | CSRF Risk | Recommendation |
|---|---|---|---|
| localStorage | High | None | Avoid for sensitive tokens |
| sessionStorage | High | None | Acceptable for short-lived tokens |
| HttpOnly cookie | None | Medium | Best for server-rendered apps |
| Memory (variable) | Low | None | Best for SPAs with BFF |
For single-page applications, the Backend-for-Frontend (BFF) pattern is the most secure approach. The tokens never reach the browser:
// Server-side BFF endpoint
app.post('/api/login/callback', async (req, res) => {
const { code } = req.body;
// Exchange code on the server where client_secret is safe
const tokens = await exchangeCode(code);
// Store tokens in an encrypted, HttpOnly session cookie
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
req.session.idToken = tokens.id_token;
res.json({ user: parseIdToken(tokens.id_token) });
});
// Proxy API calls with the stored access token
app.use('/api/proxy', async (req, res) => {
const response = await fetch(`https://api.example.com${req.path}`, {
headers: {
'Authorization': `Bearer ${req.session.accessToken}`,
},
});
res.status(response.status).json(await response.json());
});
Token Refresh
Access tokens expire. Refresh them transparently before API calls fail:
async function fetchWithAuth(url, options = {}) {
let response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${getAccessToken()}`,
},
});
if (response.status === 401) {
const refreshed = await refreshAccessToken();
if (refreshed) {
response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${getAccessToken()}`,
},
});
} else {
// Refresh token expired, redirect to login
startLogin();
}
}
return response;
}
Common Security Mistakes
Not validating the state parameter: Without state validation, an attacker can craft a malicious authorization URL and complete the flow with their own authorization code, linking their account to the victim’s session.
Storing tokens in localStorage: Any XSS vulnerability gives an attacker full access to the tokens. Use HttpOnly cookies or the BFF pattern instead.
Using the implicit flow: The implicit flow returns tokens in the URL fragment, exposing them in browser history and server logs. Always use the authorization code flow with PKCE.
Not validating the ID token signature: Accepting an ID token without verifying its signature means an attacker can forge identity claims. Fetch the provider’s JWKS and verify the signature server-side.
Requesting excessive scopes: Request only the scopes your application needs. Broad scopes increase the blast radius if a token is compromised.
Key Takeaways
OAuth 2.0 handles authorization while OIDC adds authentication through ID tokens. Use the authorization code flow with PKCE for all web applications. Validate ID token claims including issuer, audience, and expiration. Store tokens server-side with the BFF pattern when possible, or in HttpOnly cookies for server-rendered apps. Refresh access tokens transparently and handle refresh token expiration by redirecting to login. Avoid the implicit flow, localStorage token storage, and excessive scope requests.
Related articles
- REST APIs REST API Authentication: API Keys, JWT, and OAuth 2.0
Learn the three most common REST API authentication methods. Compare API keys, JWT tokens, and OAuth 2.0 with working code examples and security best practices.
- Web Content Security Policy Explained
A practical tour of CSP: what each directive does, how nonces and hashes work, how to roll out a policy safely with report-only mode, and the mistakes that quietly weaken it.
- Web Cookies vs localStorage vs sessionStorage
Compare the three main browser storage mechanisms by lifecycle, capacity, security, and use case so you pick the right tool for tokens, preferences, and state.
- Web CORS Explained In Depth
Understand Cross-Origin Resource Sharing: simple vs preflight requests, credentials, and the headers that decide whether your browser will let your fetch succeed.