OAuth 2.0: Authorization Code Flow Explained
OAuth 2.0 solves a fundamental problem: how do you grant a third-party application access to a user's resources without sharing the user's credentials? Before OAuth, users would hand over their...
Key Insights
- Authorization Code Flow is the most secure OAuth 2.0 flow for web applications because it never exposes access tokens to the browser—only a temporary authorization code that’s exchanged server-side
- PKCE (Proof Key for Code Exchange) is now recommended even for confidential clients to prevent authorization code interception attacks, making it essential for modern implementations
- The state parameter isn’t optional—it’s your primary defense against CSRF attacks and should be a cryptographically random value tied to the user’s session
Understanding OAuth 2.0 and Authorization Code Flow
OAuth 2.0 solves a fundamental problem: how do you grant a third-party application access to a user’s resources without sharing the user’s credentials? Before OAuth, users would hand over their username and password to third-party apps—a security nightmare.
The Authorization Code Flow is the gold standard for web applications with a backend server. Unlike implicit flow (deprecated) or client credentials flow (for machine-to-machine), this flow keeps access tokens away from the browser, significantly reducing attack surface.
Four key players participate in this dance:
- Resource Owner: The user who owns the data
- Client: Your application requesting access
- Authorization Server: The service that authenticates the user and issues tokens (e.g., Google, GitHub)
- Resource Server: The API that hosts the protected resources
The beauty of this flow is the separation of concerns. Your application never sees the user’s password, and the authorization server never sees your application’s backend code.
The Authorization Code Flow Step-by-Step
Here’s how the complete flow works:
// Step 1: User clicks "Login with Provider" in your app
// Your app redirects to authorization server
// Step 2: Authorization server shows login page
// User authenticates with their credentials
// User grants consent to requested permissions
// Step 3: Authorization server redirects back to your app
// URL: https://yourapp.com/callback?code=AUTH_CODE&state=STATE_VALUE
// Step 4: Your backend exchanges the code for tokens
// POST to token endpoint with code + client credentials
// Step 5: Authorization server returns tokens
// Response: { access_token, refresh_token, expires_in, token_type }
// Step 6: Your app uses access_token to call APIs
// Authorization: Bearer ACCESS_TOKEN
The critical security feature: the authorization code is useless without your client secret (or PKCE verifier), which only your backend knows. Even if an attacker intercepts the code, they can’t exchange it for tokens.
Building the Authorization Request
The authorization URL must include specific parameters. Missing or incorrect parameters will cause the flow to fail.
import crypto from 'crypto';
function generateAuthorizationUrl(config) {
// Generate cryptographically secure state value
const state = crypto.randomBytes(32).toString('hex');
// Store state in session for later validation
// (in production, use Redis or session store)
global.pendingStates = global.pendingStates || new Map();
global.pendingStates.set(state, {
timestamp: Date.now(),
// Store any app state you need to restore after callback
returnTo: config.returnTo || '/'
});
const params = new URLSearchParams({
client_id: config.clientId,
redirect_uri: config.redirectUri,
response_type: 'code',
scope: config.scope || 'read:user user:email',
state: state,
// Some providers support additional parameters
access_type: 'offline', // Request refresh token
prompt: 'consent' // Force consent screen
});
return `${config.authorizationEndpoint}?${params.toString()}`;
}
// Usage
const authUrl = generateAuthorizationUrl({
clientId: process.env.OAUTH_CLIENT_ID,
redirectUri: 'https://yourapp.com/auth/callback',
authorizationEndpoint: 'https://provider.com/oauth/authorize',
scope: 'read:user user:email',
returnTo: '/dashboard'
});
// Redirect user to authUrl
The state parameter is non-negotiable. It prevents CSRF attacks where an attacker tricks a user into authorizing access to the attacker’s account. Always generate a unique, unpredictable value and validate it on callback.
Handling the Callback and Token Exchange
When the authorization server redirects back to your application, you must validate the state and exchange the code for tokens.
import express from 'express';
import crypto from 'crypto';
const app = express();
app.get('/auth/callback', async (req, res) => {
const { code, state, error } = req.query;
// Handle authorization denial
if (error) {
return res.status(400).send(`Authorization failed: ${error}`);
}
// Validate state parameter (CSRF protection)
const storedState = global.pendingStates?.get(state);
if (!storedState) {
return res.status(400).send('Invalid state parameter');
}
// Clean up used state
global.pendingStates.delete(state);
// Check state hasn't expired (5 minutes)
if (Date.now() - storedState.timestamp > 5 * 60 * 1000) {
return res.status(400).send('State expired');
}
try {
// Exchange authorization code for tokens
const tokenResponse = await fetch('https://provider.com/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: process.env.OAUTH_REDIRECT_URI,
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET
})
});
if (!tokenResponse.ok) {
const error = await tokenResponse.text();
throw new Error(`Token exchange failed: ${error}`);
}
const tokens = await tokenResponse.json();
// tokens = { access_token, refresh_token, expires_in, token_type }
// Store tokens securely (encrypted session, database, etc.)
req.session.tokens = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + (tokens.expires_in * 1000)
};
// Redirect to original destination
res.redirect(storedState.returnTo);
} catch (error) {
console.error('Token exchange error:', error);
res.status(500).send('Authentication failed');
}
});
Never expose this endpoint to CORS or make it accept POST requests from browsers. It should only handle GET requests from server-side redirects.
Using Access Tokens and Refresh Flow
Once you have an access token, use it to make authenticated API requests. Implement automatic token refresh to handle expiration gracefully.
class OAuthClient {
constructor(tokens, config) {
this.tokens = tokens;
this.config = config;
}
async request(url, options = {}) {
// Check if token is expired
if (Date.now() >= this.tokens.expiresAt - 60000) {
await this.refreshAccessToken();
}
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.tokens.accessToken}`,
'Accept': 'application/json'
}
});
// Handle token expiration during request
if (response.status === 401) {
await this.refreshAccessToken();
// Retry request with new token
return this.request(url, options);
}
return response;
}
async refreshAccessToken() {
if (!this.tokens.refreshToken) {
throw new Error('No refresh token available');
}
const response = await fetch(this.config.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.tokens.refreshToken,
client_id: this.config.clientId,
client_secret: this.config.clientSecret
})
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
const newTokens = await response.json();
this.tokens.accessToken = newTokens.access_token;
this.tokens.expiresAt = Date.now() + (newTokens.expires_in * 1000);
// Some providers rotate refresh tokens
if (newTokens.refresh_token) {
this.tokens.refreshToken = newTokens.refresh_token;
}
// Persist updated tokens
await this.saveTokens();
}
async saveTokens() {
// Implement token persistence (database, encrypted session, etc.)
}
}
// Usage
const client = new OAuthClient(req.session.tokens, {
tokenEndpoint: 'https://provider.com/oauth/token',
clientId: process.env.OAUTH_CLIENT_ID,
clientSecret: process.env.OAUTH_CLIENT_SECRET
});
const userResponse = await client.request('https://api.provider.com/user');
const userData = await userResponse.json();
Security Best Practices
PKCE (Proof Key for Code Exchange) adds an additional layer of security by preventing authorization code interception attacks. It’s now recommended for all clients, not just public clients.
import crypto from 'crypto';
function generatePKCEChallenge() {
// Generate code verifier (random string)
const codeVerifier = crypto
.randomBytes(32)
.toString('base64url');
// Generate code challenge (SHA256 hash of verifier)
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
return {
codeVerifier,
codeChallenge,
codeChallengeMethod: 'S256'
};
}
// During authorization request
const pkce = generatePKCEChallenge();
// Store code_verifier in session
req.session.codeVerifier = pkce.codeVerifier;
// Add to authorization URL
const params = new URLSearchParams({
// ... other parameters
code_challenge: pkce.codeChallenge,
code_challenge_method: pkce.codeChallengeMethod
});
// During token exchange
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
body: new URLSearchParams({
// ... other parameters
code_verifier: req.session.codeVerifier
})
});
Additional critical security measures:
- Always use HTTPS in production—OAuth over HTTP is completely insecure
- Store tokens encrypted at rest, never in localStorage or cookies without HttpOnly flag
- Validate redirect_uri strictly—don’t allow open redirects
- Implement token rotation for refresh tokens
- Set minimal scopes—only request permissions you actually need
- Use short-lived access tokens (15-60 minutes) with refresh tokens for long-term access
Practical Implementation with GitHub
Here’s a complete minimal implementation using GitHub OAuth:
import express from 'express';
import session from 'express-session';
import crypto from 'crypto';
const app = express();
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: true, httpOnly: true }
}));
// Environment variables needed:
// GITHUB_CLIENT_ID
// GITHUB_CLIENT_SECRET
// SESSION_SECRET
app.get('/login', (req, res) => {
const state = crypto.randomBytes(32).toString('hex');
req.session.oauthState = state;
const params = new URLSearchParams({
client_id: process.env.GITHUB_CLIENT_ID,
redirect_uri: 'http://localhost:3000/auth/callback',
scope: 'read:user user:email',
state: state
});
res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});
app.get('/auth/callback', async (req, res) => {
const { code, state } = req.query;
if (state !== req.session.oauthState) {
return res.status(400).send('Invalid state');
}
delete req.session.oauthState;
try {
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET,
code: code
})
});
const tokens = await tokenResponse.json();
req.session.accessToken = tokens.access_token;
// Fetch user info
const userResponse = await fetch('https://api.github.com/user', {
headers: { 'Authorization': `Bearer ${tokens.access_token}` }
});
const user = await userResponse.json();
req.session.user = user;
res.redirect('/dashboard');
} catch (error) {
console.error(error);
res.status(500).send('Authentication failed');
}
});
app.get('/dashboard', (req, res) => {
if (!req.session.user) {
return res.redirect('/login');
}
res.json({ user: req.session.user });
});
app.listen(3000);
This implementation covers the essentials: state validation, token exchange, and authenticated requests. For production, add PKCE, proper error handling, token refresh logic, and secure token storage with encryption.
OAuth 2.0 Authorization Code Flow is complex, but understanding each component and implementing proper security measures will give you a robust, secure authentication system that protects both your users and your application.