Introduction
OAuth 2.0 is the industry-standard protocol for authorization, but it is also one of the most misunderstood. Developers frequently confuse authentication with authorization, implement the wrong grant type for their use case, or introduce security vulnerabilities by cutting corners. A single mistake in your OAuth implementation can expose user data, compromise API keys, or allow token theft.
The OAuth 2.0 specification defines several grant types, each designed for a specific scenario. The Authorization Code grant is for web applications with a server component. The Client Credentials grant is for machine-to-machine communication. The Implicit grant was designed for browser-based applications but is now deprecated. The Device Authorization grant handles input-constrained devices like smart TVs.
Understanding which flow to use and how to implement it correctly is critical for building secure applications. This guide breaks down every OAuth 2.0 flow, explains when to use each one, covers security considerations like PKCE and token storage, and provides implementation examples with real code.
Understanding OAuth 2.0: Core Concepts
The OAuth 2.0 Model
OAuth 2.0 defines four roles: the resource owner (the user), the client (the application requesting access), the authorization server (issues tokens), and the resource server (hosts protected resources). The fundamental flow is: the client requests authorization from the resource owner, receives an authorization grant, exchanges it for an access token, and uses the token to access protected resources.
This model separates authentication from authorization. The client never sees the user credentials. Instead, the authorization server validates the credentials and issues tokens that grant limited access to specific resources for a specific duration.
Access Tokens and Refresh Tokens
An access token is a credential that represents the authorization granted to the client. It is typically a JWT (JSON Web Token) containing claims about the user and the scope of access. Access tokens are short-lived, usually 15 minutes to 1 hour.
A refresh token is a long-lived credential used to obtain new access tokens without requiring the user to re-authenticate. Refresh tokens are typically opaque strings stored securely on the server side. They can be revoked to invalidate all associated access tokens.
// JWT access token structure
{
"header": {
"alg": "RS256",
"typ": "JWT",
"kid": "key-id-123"
},
"payload": {
"sub": "user-123",
"iss": "https://auth.example.com",
"aud": "https://api.example.com",
"exp": 1609459200,
"iat": 1609455600,
"scope": "read write",
"email": "user@example.com"
}
}Scopes
Scopes define the specific permissions that the client is requesting. They are space-delimited strings that the authorization server presents to the user during the consent screen. Common scopes include openid, profile, email, read, write, and admin.
// Authorization request with scopes
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', 'my-client-id');
authUrl.searchParams.set('redirect_uri', 'https://app.example.com/callback');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', generateState());OAuth 2.0 vs OpenID Connect
OAuth 2.0 is an authorization protocol. OpenID Connect (OIDC) is an authentication layer built on top of OAuth 2.0. OIDC adds an ID token (a JWT containing user identity claims) and a UserInfo endpoint that returns profile information. If you need to authenticate users (know who they are), use OIDC. If you only need to authorize access (know what they can do), use OAuth 2.0.
Architecture and Design Patterns
Authorization Code Grant
The Authorization Code grant is the most secure and widely used OAuth 2.0 flow. It is designed for applications that have a server component where the client secret can be stored securely.
The flow works as follows: the client redirects the user to the authorization server, the user authenticates and consents, the authorization server redirects back with an authorization code, and the client exchanges the code for tokens on the server side.
// Step 1: Generate authorization URL
function getAuthorizationUrl(): string {
const state = crypto.randomBytes(32).toString('hex');
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// Store state and codeVerifier in session
session.oauthState = state;
session.codeVerifier = codeVerifier;
const url = new URL('https://auth.example.com/authorize');
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', process.env.CLIENT_ID);
url.searchParams.set('redirect_uri', 'https://app.example.com/callback');
url.searchParams.set('scope', 'openid profile email');
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', codeChallenge);
url.searchParams.set('code_challenge_method', 'S256');
return url.toString();
}
// Step 2: Handle callback and exchange code for tokens
async function handleCallback(code: string, state: string) {
if (state !== session.oauthState) {
throw new Error('Invalid state parameter');
}
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://app.example.com/callback',
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
code_verifier: session.codeVerifier,
}),
});
const tokens = await tokenResponse.json();
// tokens.access_token, tokens.refresh_token, tokens.id_token
return tokens;
}Client Credentials Grant
The Client Credentials grant is used for machine-to-machine communication where there is no user involved. The client authenticates directly with the authorization server using its own credentials.
async function getClientToken(): Promise<string> {
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${Buffer.from(
`${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`
).toString('base64')}`,
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'api:read api:write',
}),
});
const data = await response.json();
return data.access_token;
}Device Authorization Grant
The Device Authorization grant is designed for input-constrained devices like smart TVs, gaming consoles, and CLI tools. The device displays a code and URL, the user visits the URL on another device and enters the code, and the device polls the authorization server until the user completes authorization.
async function startDeviceFlow() {
const response = await fetch('https://auth.example.com/device/code', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: process.env.CLIENT_ID,
scope: 'openid profile',
}),
});
const data = await response.json();
// data.device_code, data.user_code, data.verification_uri, data.expires_in, data.interval
console.log(`Visit ${data.verification_uri} and enter code: ${data.user_code}`);
// Poll for token
while (true) {
await sleep(data.interval * 1000);
const tokenResponse = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: data.device_code,
client_id: process.env.CLIENT_ID,
}),
});
const tokens = await tokenResponse.json();
if (tokens.access_token) {
return tokens;
}
if (tokens.error === 'authorization_pending') {
continue;
}
if (tokens.error === 'slow_down') {
data.interval += 5;
continue;
}
throw new Error(`Device flow failed: ${tokens.error}`);
}
}Step-by-Step Implementation
Complete Authorization Code Flow with Express
import express from 'express';
import session from 'express-session';
import crypto from 'crypto';
const app = express();
app.use(session({ secret: 'session-secret', resave: false, saveUninitialized: true }));
const AUTH_SERVER = 'https://auth.example.com';
const CLIENT_ID = process.env.CLIENT_ID;
const CLIENT_SECRET = process.env.CLIENT_SECRET;
const REDIRECT_URI = 'http://localhost:3000/callback';
// Login: redirect to authorization server
app.get('/login', (req, res) => {
const state = crypto.randomBytes(32).toString('hex');
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
req.session.oauthState = state;
req.session.codeVerifier = codeVerifier;
const url = new URL(`${AUTH_SERVER}/authorize`);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', CLIENT_ID);
url.searchParams.set('redirect_uri', REDIRECT_URI);
url.searchParams.set('scope', 'openid profile email');
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', codeChallenge);
url.searchParams.set('code_challenge_method', 'S256');
res.redirect(url.toString());
});
// Callback: exchange code for tokens
app.get('/callback', async (req, res) => {
const { code, state, error } = req.query;
if (error) {
return res.status(400).json({ error: req.query.error_description });
}
if (state !== req.session.oauthState) {
return res.status(403).json({ error: 'Invalid state' });
}
try {
const tokenResponse = await fetch(`${AUTH_SERVER}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code as string,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code_verifier: req.session.codeVerifier,
}),
});
const tokens = await tokenResponse.json();
if (tokens.error) {
return res.status(400).json({ error: tokens.error_description });
}
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
res.redirect('/dashboard');
} catch (err) {
res.status(500).json({ error: 'Token exchange failed' });
}
});
// Protected route
app.get('/dashboard', async (req, res) => {
if (!req.session.accessToken) {
return res.redirect('/login');
}
try {
const response = await fetch(`${AUTH_SERVER}/userinfo`, {
headers: { Authorization: `Bearer ${req.session.accessToken}` },
});
if (response.status === 401) {
// Token expired, try refresh
const refreshed = await refreshToken(req.session.refreshToken);
if (refreshed) {
req.session.accessToken = refreshed.access_token;
req.session.refreshToken = refreshed.refresh_token;
return res.redirect('/dashboard');
}
return res.redirect('/login');
}
const user = await response.json();
res.json({ message: `Welcome, ${user.name}`, user });
} catch (err) {
res.status(500).json({ error: 'Failed to fetch user info' });
}
});
async function refreshToken(refreshToken: string) {
const response = await fetch(`${AUTH_SERVER}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
if (!response.ok) return null;
return response.json();
}Real-World Use Cases
Use Case 1: Single Sign-On for Web Application
A web application needs to authenticate users through a centralized identity provider. The Authorization Code flow with PKCE is the correct choice, even for server-side applications, because PKCE protects against authorization code interception attacks.
Use Case 2: Microservice-to-Microservice Communication
When one microservice needs to call another microservice API, the Client Credentials grant is appropriate. There is no user involved, and the service authenticates with its own credentials.
// Service-to-service authentication
class ServiceClient {
private token: string | null = null;
private tokenExpiry: number = 0;
async getToken(): Promise<string> {
if (this.token && Date.now() < this.tokenExpiry) {
return this.token;
}
const response = await fetch(`${AUTH_SERVER}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.SERVICE_CLIENT_ID,
client_secret: process.env.SERVICE_CLIENT_SECRET,
scope: 'service:read service:write',
}),
});
const data = await response.json();
this.token = data.access_token;
this.tokenExpiry = Date.now() + (data.expires_in * 1000) - 60000;
return this.token;
}
async callService(url: string, options: RequestInit = {}) {
const token = await this.getToken();
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
});
}
}Use Case 3: Mobile Application
Mobile applications should use the Authorization Code flow with PKCE. The Implicit flow is deprecated because tokens in URLs can be intercepted. PKCE eliminates the need for a client secret, which cannot be securely stored in a mobile app.
Use Case 4: CLI Tool
A CLI tool that needs to access a user account uses the Device Authorization flow. The tool displays a URL and code, the user authorizes on their browser, and the tool polls for the token.
Best Practices for Production
-
Always use PKCE: Even for server-side applications, PKCE protects against authorization code interception. There is no reason not to use it.
-
Never store tokens in localStorage: localStorage is vulnerable to XSS attacks. Store tokens in HTTP-only, secure, SameSite cookies or in server-side sessions.
-
Validate the state parameter: The state parameter prevents CSRF attacks. Generate a random state value, store it in the session, and validate it when the callback arrives.
-
Use short-lived access tokens: Set access token expiry to 15-60 minutes. Use refresh tokens for long-lived sessions.
-
Implement token refresh: Handle token expiry gracefully by refreshing tokens before they expire or on the first 401 response.
-
Validate JWT tokens: When receiving JWT tokens, validate the signature, issuer, audience, and expiry. Use a well-tested library like
joseorjsonwebtoken. -
Rotate refresh tokens: When a refresh token is used, issue a new refresh token and invalidate the old one. This limits the window of token theft.
-
Implement token revocation: Provide an endpoint to revoke tokens when users log out or change their password.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Not using PKCE | Authorization code interception | Always use code_challenge and code_verifier |
| Storing tokens in localStorage | XSS-based token theft | Use HTTP-only cookies or server-side sessions |
| Not validating state parameter | CSRF attacks | Generate random state, validate on callback |
| Long-lived access tokens | Stolen tokens grant extended access | Use short expiry with refresh tokens |
| Exposing client secret in frontend | Client impersonation | Use PKCE for public clients, keep secrets server-side |
| Not validating JWT signature | Forged tokens accepted | Validate signature, issuer, audience, and expiry |
| Reusing refresh tokens indefinitely | Stolen refresh token grants permanent access | Rotate refresh tokens on each use |
Performance Optimization
Token Caching
Cache access tokens for machine-to-machine flows to avoid requesting a new token for every API call.
class TokenCache {
private cache = new Map<string, { token: string; expiry: number }>();
async getToken(key: string, fetcher: () => Promise<TokenResponse>): Promise<string> {
const cached = this.cache.get(key);
if (cached && Date.now() < cached.expiry) {
return cached.token;
}
const response = await fetcher();
this.cache.set(key, {
token: response.access_token,
expiry: Date.now() + (response.expires_in * 1000) - 60000,
});
return response.access_token;
}
}JWT Validation Caching
Cache the JWKS (JSON Web Key Set) from the authorization server to avoid fetching it for every token validation.
import { createRemoteJWKSet, jwtVerify } from 'jose';
// Cache JWKS with automatic refresh
const JWKS = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json'),
{ cooldownDuration: 600000 } // 10 minutes
);
async function verifyToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
return payload;
}Testing Strategies
import { generateKeyPair, SignJWT } from 'jose';
async function createTestToken(claims: Record<string, any> = {}) {
const { privateKey } = await generateKeyPair('RS256');
return new SignJWT({
sub: 'user-123',
iss: 'https://auth.example.com',
aud: 'https://api.example.com',
exp: Math.floor(Date.now() / 1000) + 3600,
...claims,
})
.setProtectedHeader({ alg: 'RS256' })
.sign(privateKey);
}
// Test protected endpoint
test('returns 401 without token', async () => {
const res = await request(app).get('/api/protected');
expect(res.status).toBe(401);
});
test('returns 200 with valid token', async () => {
const token = await createTestToken();
const res = await request(app)
.get('/api/protected')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
});Future Outlook
OAuth 2.1 consolidates the best practices from OAuth 2.0 into a single specification. PKCE becomes mandatory for all clients, the Implicit grant is removed, and refresh token rotation is recommended. These changes simplify the protocol and improve security by default.
DPoP (Demonstrating Proof-of-Possession) is an emerging standard that binds access tokens to the client that requested them, preventing token theft and replay attacks. This addresses one of the remaining security gaps in OAuth 2.0 where stolen bearer tokens can be used by anyone.
OAuth 2.0 Security Best Practices
Always use PKCE (Proof Key for Code Exchange) with the authorization code flow, even for server-side applications. PKCE prevents authorization code interception attacks by requiring the client to prove it initiated the authorization request. Use short-lived access tokens with refresh token rotation to limit the window of exposure if a token is compromised. Validate the state parameter on every authorization callback to prevent CSRF attacks. Store tokens securely using HTTP-only cookies or secure storage mechanisms, never in localStorage where they are accessible to XSS attacks. Rotate your client secrets periodically and monitor your OAuth provider's audit logs for suspicious activity.
Conclusion
OAuth 2.0 is the foundation of modern API security, but implementing it correctly requires understanding the nuances of each grant type and the security implications of every design decision.
Key takeaways:
- Use the Authorization Code flow with PKCE for all user-facing applications
- Use the Client Credentials flow for machine-to-machine communication
- Never use the Implicit grant for new applications
- Always validate the state parameter to prevent CSRF attacks
- Store tokens securely: HTTP-only cookies for web apps, secure storage for mobile apps
- Use short-lived access tokens with refresh tokens for long sessions
- Validate JWT signatures, issuer, audience, and expiry on every request
- Implement token revocation and refresh token rotation for maximum security
Start with the Authorization Code flow with PKCE as your default choice. It covers the majority of use cases and provides the strongest security guarantees. Only deviate from it when your specific requirements demand a different grant type.