The OAuth 2.0 Authorization Code flow has three main steps, two redirects, and a back-channel token exchange. Every one of these was designed to prevent a specific attack. When developers skip steps or implement them incorrectly, they open exactly the attack that step was there to close. This post walks through the flow step by step with concrete code, explaining what each piece does and why it's there.
Step 1: The authorization request
The flow begins when your application redirects the user to the authorization server. The URL carries everything the authorization server needs to know: who you are, what you're asking for, and where to send the user back.
function buildAuthorizationUrl(state: string, codeVerifier: string): string {
// PKCE: hash the code verifier to create the challenge
const codeChallenge = base64url(
crypto.createHash('sha256').update(codeVerifier).digest()
);
const params = new URLSearchParams({
response_type: 'code', // we want an authorization code, not tokens directly
client_id: process.env.CLIENT_ID!,
redirect_uri: 'https://app.example.com/auth/callback',
scope: 'openid email profile',
state, // CSRF protection
code_challenge: codeChallenge, // PKCE: prevents code interception
code_challenge_method: 'S256',
});
return `https://auth.example.com/authorize?${params}`;
}
The response_type: 'code' parameter is the key choice. It tells the authorization server to return an authorization code in the redirect, not the tokens themselves. Why not tokens directly? Because the redirect goes through the user's browser, and anything in a redirect URL may be logged — in server access logs, browser history, referrer headers on outbound links. A short-lived, single-use code in the redirect is far less damaging than a long-lived access token.
Step 2: User authentication and consent
The authorization server handles authentication and, if the user hasn't previously consented, shows a consent screen. Your application has no visibility into this step — the user is on the authorization server's domain, not yours. This is intentional: user credentials never pass through your application.
After the user authenticates and consents, the authorization server redirects back to your redirect_uri with a code parameter and the state you sent.
// Your callback endpoint receives:
// GET /auth/callback?code=abc123&state=xyz789
async function handleCallback(req: Request, res: Response) {
const { code, state, error } = req.query as Record<string, string>;
if (error) {
// User denied consent, or an error occurred
return res.redirect(`/login?error=${encodeURIComponent(error)}`);
}
// 1. Verify state FIRST — before doing anything with the code
const storedState = await redis.get(`oauth_state:${req.session.id}`);
if (!storedState || storedState !== state) {
return res.status(400).send('State mismatch — possible CSRF attack');
}
await redis.del(`oauth_state:${req.session.id}`);
// 2. The code is single-use and short-lived (typically 60-600 seconds)
// Exchange it immediately
const tokens = await exchangeCodeForTokens(code, req.session.codeVerifier);
// ... create session
}
Why the state parameter prevents CSRF
Without state, an attacker can craft an authorization URL, get the victim to click it (via an email, an img tag, or a direct link), and then capture the resulting code. The victim ends up logged in to the attacker's session (login CSRF). The state parameter breaks this: the victim's browser sends the authorization request with a random state value that the attacker doesn't know, and the callback verifier rejects any state it didn't generate itself.
Step 3: The token endpoint exchange
The authorization code is exchanged for tokens at the token endpoint. This exchange happens server-to-server — your backend calls the authorization server's token endpoint directly, not through the browser.
async function exchangeCodeForTokens(
code: string,
codeVerifier: string
): Promise<TokenResponse> {
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
// Client authentication: identify who is making this request
'Authorization': `Basic ${Buffer.from(
`${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`
).toString('base64')}`,
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: 'https://app.example.com/auth/callback',
code_verifier: codeVerifier, // PKCE: proves we initiated the flow
}),
});
if (!response.ok) {
const error = await response.json();
throw new AuthError(error.error, error.error_description);
}
return response.json();
}
Why the back-channel exchange matters
The back-channel exchange is what makes Authorization Code fundamentally different from the Implicit flow (which returns tokens directly in the redirect). By requiring the code to be exchanged at a server-to-server endpoint, you get two security properties:
- Client authentication: The token endpoint requires your client credentials. Only your server can exchange codes. An attacker who intercepts a code from a redirect URL cannot use it without your client secret — and your client secret lives only on your server.
- Code single-use enforcement: The authorization server marks each code as used on first redemption. If an attacker does intercept a code and tries to use it after you've already exchanged it, the authorization server will reject it. If they get there first, your exchange will fail — alerting you to the interception.
What PKCE adds
PKCE (Proof Key for Code Exchange) handles the case where you can't keep a client secret — public clients like SPAs and mobile apps. The code verifier (a random string generated before the authorization request) binds the code to the client that initiated the flow. Even if an attacker intercepts the authorization code, they cannot exchange it without the code verifier, which was never transmitted over the redirect channel. For all new OAuth implementations, always use PKCE regardless of whether you have a client secret.
redirect_uri in the token exchange must exactly match the one in the original authorization request and must match the URI registered with the authorization server. This triple verification — registration, request, and exchange — prevents open redirect attacks where a modified redirect_uri sends the code to an attacker's server.