Understanding OAuth 2.0 Flow
Every time you click "Sign in with Google" or authorize a third-party app to access your GitHub repos, OAuth 2.0 is working behind the scenes. Yet despite its ubiquity, OAuth 2.0 remains one of the most misunderstood protocols in modern web development. Developers often confuse it with authentication, implement the wrong grant type, or leave security holes that attackers are eager to exploit. This guide cuts through the confusion — you'll understand what OAuth 2.0 actually is, how each grant type works step-by-step, and how to implement it securely in real applications.
OAuth 2.0 is an authorization framework, not an authentication protocol. It grants access to resources on behalf of a user — it does not prove who that user is. For authentication (login), you need OpenID Connect (OIDC), which builds on top of OAuth 2.0.
What Is OAuth 2.0?
OAuth 2.0 (Open Authorization) is an industry-standard framework that allows a third-party application to obtain limited access to a service on behalf of a user, without exposing the user's credentials. It was designed to replace OAuth 1.0 with a simpler, more secure approach.
The core problem OAuth 2.0 solves: delegated access. Before OAuth, if you wanted a photo-printing app to access your Google Photos, you'd have to give the app your Google password — which is terrible security. OAuth 2.0 lets Google issue a limited-scope token to the printing app without ever sharing your password.
The Four Key Roles
- Resource Owner: The user who owns the data and can grant access to it
- Client: The application requesting access (the photo-printing app)
- Authorization Server: Issues access tokens after authenticating the user (Google's auth server)
- Resource Server: Hosts the protected resources (Google Photos API)
The four OAuth 2.0 roles and how they interact
OAuth 2.0 Grant Types
OAuth 2.0 defines several "grant types" — each is a different flow for obtaining an access token, suited to different client types and use cases.
| Grant Type | Best For | Status |
|---|---|---|
| Authorization Code + PKCE | Web apps, SPAs, mobile apps | ✅ Recommended |
| Client Credentials | Machine-to-machine (no user) | ✅ Recommended |
| Implicit | SPAs (legacy) | ⛔ Deprecated |
| Resource Owner Password | First-party apps only | ⛔ Deprecated |
| Device Authorization | Smart TVs, CLI tools | ✅ Supported |
The Implicit and Resource Owner Password Credentials grants are deprecated in OAuth 2.1. The Implicit flow exposes tokens in URLs where they can be stolen from browser history or referrer headers. Always use Authorization Code + PKCE instead.
Authorization Code Flow (with PKCE)
The Authorization Code flow is the gold standard for OAuth 2.0. It's used when your application can securely store a client secret — or in the case of SPAs and mobile apps, it's enhanced with PKCE (Proof Key for Code Exchange) to protect against authorization code interception attacks.
Step-by-Step: Authorization Code + PKCE
Generate PKCE Verifier and Challenge
The client generates a random code_verifier string and derives a code_challenge from it using SHA-256. This pair proves the auth request and token exchange come from the same client.
Redirect User to Authorization Server
The client redirects the user's browser to the authorization endpoint with the code_challenge, requested scopes, and a redirect_uri.
User Authenticates and Consents
The authorization server shows the user a login page and consent screen. After approval, it redirects back to the client with a short-lived authorization_code.
Exchange Code for Tokens
The client sends the authorization_code along with the original code_verifier directly to the token endpoint (server-to-server). The authorization server verifies the pair and returns access and refresh tokens.
Access Protected Resources
The client includes the access_token in API requests as a Bearer token in the Authorization header.
Authorization Code + PKCE flow — the recommended approach for all app types
Implementing PKCE in JavaScript
// Generate a cryptographically random code_verifier
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// Derive code_challenge from verifier using SHA-256
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// Build authorization URL and redirect
async function startOAuthFlow() {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
// Store verifier in sessionStorage — needed for token exchange
sessionStorage.setItem('pkce_verifier', verifier);
const params = new URLSearchParams({
response_type: 'code',
client_id: 'YOUR_CLIENT_ID',
redirect_uri: 'https://yourapp.com/callback',
scope: 'read:profile read:email',
state: crypto.randomUUID(), // CSRF protection
code_challenge: challenge,
code_challenge_method: 'S256',
});
window.location.href = `https://auth.example.com/authorize?${params}`;
}
// On the redirect callback page
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
const verifier = sessionStorage.getItem('pkce_verifier');
if (!code || !verifier) {
throw new Error('Missing code or 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',
code,
redirect_uri: 'https://yourapp.com/callback',
client_id: 'YOUR_CLIENT_ID',
code_verifier: verifier,
}),
});
const tokens = await response.json();
// { access_token, refresh_token, token_type, expires_in }
// Store tokens securely — httpOnly cookies are preferred
// Never store access tokens in localStorage
sessionStorage.removeItem('pkce_verifier');
return tokens;
}
Client Credentials Flow
When there is no user involved — a background service calling another service, a cron job fetching data from an API, a microservice authenticating to another microservice — the Client Credentials grant is the right choice.
It's the simplest OAuth 2.0 flow: the client authenticates directly with the authorization server using its own credentials (client ID + secret) and receives an access token. No user redirect, no consent screen.
const axios = require('axios');
class OAuthClient {
constructor(clientId, clientSecret, tokenUrl) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.tokenUrl = tokenUrl;
this.token = null;
this.tokenExpiry = null;
}
async getAccessToken() {
// Return cached token if still valid (with 60s buffer)
if (this.token && Date.now() < this.tokenExpiry - 60000) {
return this.token;
}
const response = await axios.post(
this.tokenUrl,
new URLSearchParams({
grant_type: 'client_credentials',
scope: 'api:read api:write',
}),
{
auth: {
username: this.clientId,
password: this.clientSecret,
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}
);
this.token = response.data.access_token;
this.tokenExpiry = Date.now() + response.data.expires_in * 1000;
return this.token;
}
async callAPI(endpoint) {
const token = await this.getAccessToken();
return axios.get(endpoint, {
headers: { Authorization: `Bearer ${token}` },
});
}
}
// Usage
const client = new OAuthClient(
process.env.CLIENT_ID,
process.env.CLIENT_SECRET,
'https://auth.example.com/token'
);
const data = await client.callAPI('https://api.example.com/reports');
Always cache access tokens until they expire. Requesting a new token on every API call wastes time and may hit rate limits on the authorization server. The example above caches the token and only refreshes it 60 seconds before expiry.
Understanding OAuth 2.0 Tokens
OAuth 2.0 uses three main types of tokens, each with a different purpose and lifetime.
Short-lived credential used to access protected resources. Typically expires in 15 minutes to 1 hour.
Usually a JWT (JSON Web Token) that the resource server can validate without calling the authorization server:
// Decoded access token (JWT)
{
"iss": "https://auth.example.com",
"sub": "user_12345",
"aud": "https://api.example.com",
"exp": 1746000000,
"iat": 1745996400,
"scope": "read:profile read:email",
"client_id": "my-app-client-id"
}
// Usage in API request
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...
The resource server validates the JWT signature using the authorization server's public key — no network call needed.
Long-lived credential used exclusively to obtain new access tokens when the current one expires. Never sent to resource servers.
// Refresh token exchange
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: storedRefreshToken,
client_id: 'YOUR_CLIENT_ID',
// client_secret required for confidential clients
}),
});
const { access_token, refresh_token } = await response.json();
// Note: authorization server may rotate the refresh token
// Always store the new one
Store refresh tokens securely. In web apps, use httpOnly cookies. In mobile apps, use the platform's secure storage (Keychain / Keystore).
OpenID Connect extension. When you request the openid scope, the authorization server also returns an ID Token — a JWT that contains identity claims about the authenticated user.
// Decoded ID token
{
"iss": "https://auth.example.com",
"sub": "user_12345",
"aud": "my-app-client-id",
"exp": 1746000000,
"iat": 1745996400,
"name": "Jane Doe",
"email": "jane@example.com",
"picture": "https://example.com/jane.jpg",
"email_verified": true
}
// Verify ID token before trusting it
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json')
);
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: 'https://auth.example.com',
audience: 'my-app-client-id',
});
Security Best Practices
OAuth 2.0 is secure by design, but many breaches come from incorrect implementation. Here are the critical security requirements every developer must follow.
1. Always Use PKCE for Public Clients
Any client that cannot securely store a client secret (SPAs, mobile apps, desktop apps) is a "public client." PKCE is not optional for these — it prevents authorization code interception attacks where a malicious app on the same device intercepts the redirect.
2. Validate the State Parameter
// Before redirect — generate and store state
const state = crypto.randomUUID();
sessionStorage.setItem('oauth_state', state);
// In the authorization URL
const params = new URLSearchParams({
// ...
state,
});
// In the callback — validate state matches
function handleCallback() {
const returnedState = new URLSearchParams(window.location.search).get('state');
const savedState = sessionStorage.getItem('oauth_state');
sessionStorage.removeItem('oauth_state');
if (returnedState !== savedState) {
throw new Error('State mismatch — possible CSRF attack');
}
// Continue with token exchange...
}
3. Strict Redirect URI Validation
Register exact redirect URIs with your authorization server — no wildcards, no partial matches. An open redirect vulnerability can allow an attacker to steal authorization codes by substituting a malicious redirect URI.
4. Store Tokens Securely
| Storage Location | Access Token | Refresh Token | Verdict |
|---|---|---|---|
| localStorage | Accessible via JS | Accessible via JS | ⛔ XSS risk |
| sessionStorage | Cleared on tab close | Not recommended | ⚠️ Still XSS risk |
| httpOnly Cookie | Not readable by JS | Secure storage | ✅ Preferred for web |
| Memory (JS variable) | Lost on page refresh | Not practical | ✅ Acceptable for access tokens |
| Keychain / Keystore | N/A | OS-managed secure storage | ✅ Required for mobile |
5. Validate JWT Signatures Server-Side
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { Request, Response, NextFunction } from 'express';
const JWKS = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json')
);
export async function requireAuth(
req: Request,
res: Response,
next: NextFunction
) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing bearer token' });
}
const token = authHeader.slice(7);
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
// Attach decoded claims to request
req.user = payload;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
JWT payloads are base64-encoded, not encrypted. Never trust a JWT by just decoding it — always verify the signature and validate the iss, aud, and exp claims. Skipping signature verification is one of the most common OAuth implementation mistakes.
Handling Token Refresh
Access tokens are short-lived by design. A robust OAuth implementation handles token refresh automatically, without requiring the user to log in again.
class AuthClient {
#accessToken = null;
#refreshToken = null;
#tokenExpiry = null;
#refreshPromise = null; // Prevent concurrent refreshes
async fetch(url, options = {}) {
const token = await this.#getValidToken();
const response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
});
// Handle token revocation mid-session
if (response.status === 401) {
this.#accessToken = null;
throw new Error('Session expired. Please log in again.');
}
return response;
}
async #getValidToken() {
// Token still valid with 30s buffer
if (this.#accessToken && Date.now() < this.#tokenExpiry - 30000) {
return this.#accessToken;
}
// Prevent concurrent refresh calls (race condition)
if (!this.#refreshPromise) {
this.#refreshPromise = this.#doRefresh().finally(() => {
this.#refreshPromise = null;
});
}
return this.#refreshPromise;
}
async #doRefresh() {
if (!this.#refreshToken) throw new Error('No refresh token available');
const res = await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include', // Include httpOnly cookie with refresh token
});
if (!res.ok) throw new Error('Token refresh failed');
const data = await res.json();
this.#accessToken = data.access_token;
this.#tokenExpiry = Date.now() + data.expires_in * 1000;
return this.#accessToken;
}
}
Key Takeaways
OAuth 2.0 Implementation Checklist
- Use Authorization Code + PKCE for all user-facing applications (web, mobile, SPA)
- Use Client Credentials for server-to-server communication without a user context
- Always validate the state parameter to prevent CSRF attacks on the callback
- Register exact redirect URIs — never use wildcards or partial matches
- Store refresh tokens in httpOnly cookies on the web; use Keychain/Keystore on mobile
- Verify JWT signatures on every request using the authorization server's JWKS endpoint
- Cache access tokens until near-expiry; implement automatic silent refresh
- Request minimal scopes — principle of least privilege applies to OAuth too
- Never use Implicit or ROPC grants in new applications — they are deprecated in OAuth 2.1
- Rotate refresh tokens on every use to limit the blast radius of a token theft
OAuth 2.0 vs OpenID Connect — Quick Reference
| Aspect | OAuth 2.0 | OpenID Connect (OIDC) |
|---|---|---|
| Purpose | Authorization (access delegation) | Authentication (identity verification) |
| Primary token | Access Token | ID Token (+ Access Token) |
| User info | Not included | Claims in ID token or /userinfo |
| Required scope | App-specific | openid (+ profile, email) |
| Answers "Who is this?" | No | Yes |
| Answers "Can they access X?" | Yes | Yes (also built on OAuth 2.0) |
| Spec | RFC 6749 | OpenID Foundation spec |
"OAuth 2.0 is a specification for building authorization flows, not a single protocol. Understanding which grant type to use — and implementing it correctly with PKCE and proper token storage — is what separates secure applications from vulnerable ones."
OAuth 2.0 underpins nearly every modern authentication system you encounter. Once you understand the core roles, token types, and how PKCE eliminates the main vulnerabilities of earlier approaches, you'll be well-equipped to implement secure delegated authorization in any application — whether it's a single-page app, a mobile client, or a microservices architecture talking to itself.