Authorization Code Flow, Step by Step
The flow you should use for almost everything — traced from the button click to the first API call.
The Authorization Code flow is OAuth's default and, with PKCE, the recommended flow for every client type. Its defining property: the token is delivered server-to-server, never through the browser's address bar.
The eight steps
- User clicks "Sign in with Google".
- Your server builds an authorize URL with
client_id,redirect_uri,scope,response_type=code, a randomstate, and (with PKCE) acode_challenge— then redirects the browser to it. - The user authenticates at the provider. Your app never sees the password.
- The user approves the scopes.
- The provider redirects back to your
redirect_uriwith?code=…&state=…. - You verify
statematches what you stored. If not, stop — this is a forged callback. - Your server POSTs the code to
/tokenalong with the client secret (or PKCE verifier). Back come the tokens. - You create your own session and use the access token to call the API.
Why the code, and not the token?
The code arrives in a URL, and URLs leak: browser history, the Referer header, server logs, shoulder-surfing. So the code is deliberately single-use, short-lived (about 60 seconds), and useless without the client secret or PKCE verifier. The valuable token only ever travels over a direct server-to-server POST.
state is not optional
state is a random value you store before redirecting and compare on return. It stops CSRF on the callback: without it, an attacker can complete their own authorization at the provider and then trick your user into visiting the callback with the attacker's code — silently linking the victim's session to the attacker's account.
redirect_uri must be exact
Providers match it against a registered list, character for character. That is what stops an attacker redirecting the code to their own server. Never implement open-ended redirect matching in your own authorization server.
Example
# Step 2 — where you send the browser
https://accounts.google.com/o/oauth2/v2/auth
?client_id=abc-web.apps.googleusercontent.com
&redirect_uri=https%3A%2F%2Fabc.com%2Fauth%2Fcallback
&response_type=code
&scope=openid%20email%20profile
&state=Qm9iX3JhbmRvbV8xMjM
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
# Step 5 — what comes back
GET https://abc.com/auth/callback?code=4/0AY0e-g7...&state=Qm9iX3JhbmRvbV8xMjM
# Step 7 — back channel, from your server
curl -X POST https://oauth2.googleapis.com/token \
-d grant_type=authorization_code \
-d code=4/0AY0e-g7... \
-d redirect_uri=https://abc.com/auth/callback \
-d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRET \
-d code_verifier=$VERIFIER
# { "access_token": "ya29...", "id_token": "eyJhbGci...",
# "refresh_token": "1//0g...", "expires_in": 3599, "token_type": "Bearer" }When to use it
- A web app adds 'Sign in with Google' and never stores a password, removing password reset, hashing and breach exposure from its scope entirely.
- A security review rejects an implementation that skipped the state parameter, which would have allowed an attacker to bind a victim's session to their own account.
- An integration stores the returned refresh token so a nightly job can keep syncing a user's calendar without them being present.
More examples
The complete flow, both routes
Note the last step: the provider's tokens never reach the browser. The user gets your session cookie, which you control, expire and revoke on your own terms.
import { randomBytes, createHash } from 'crypto';
const PROVIDER = {
authorize: 'https://accounts.google.com/o/oauth2/v2/auth',
token: 'https://oauth2.googleapis.com/token',
};
const REDIRECT_URI = 'https://abc.com/auth/callback';
// ---------- Step 2: start ----------
app.get('/auth/google', (req, res) => {
const state = randomBytes(16).toString('base64url');
const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
// Both values must survive the round-trip, and must not be readable by JS.
res.cookie('oauth_tx', JSON.stringify({ state, verifier }), {
httpOnly: true, secure: true, sameSite: 'lax',
maxAge: 10 * 60e3, path: '/auth',
});
const url = new URL(PROVIDER.authorize);
url.search = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'openid email profile',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
prompt: 'select_account',
}).toString();
res.redirect(url.toString());
});
// ---------- Steps 6-8: callback ----------
app.get('/auth/callback', async (req, res) => {
const { code, state, error } = req.query;
if (error) return res.redirect('/login?error=' + encodeURIComponent(String(error)));
const tx = JSON.parse(req.cookies.oauth_tx || '{}');
res.clearCookie('oauth_tx', { path: '/auth' });
// CSRF protection on the callback — refuse anything that does not match.
if (!tx.state || tx.state !== state) {
return res.status(400).json({ error: 'invalid_state' });
}
const tokenRes = await fetch(PROVIDER.token, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: String(code),
redirect_uri: REDIRECT_URI,
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
code_verifier: tx.verifier,
}),
});
if (!tokenRes.ok) return res.status(502).json({ error: 'token_exchange_failed' });
const { id_token, access_token, refresh_token } = await tokenRes.json();
// Identity comes from the ID TOKEN, verified — never from the access token.
const claims = await verifyIdToken(id_token);
const user = await upsertUser({
provider: 'google', providerId: claims.sub,
email: claims.email, emailVerified: claims.email_verified, name: claims.name,
});
if (refresh_token) await storeProviderRefreshToken(user.id, refresh_token);
// Your own session — the provider's tokens stay on the server.
res.cookie('sid', await createSession(user.id, req), COOKIE_OPTIONS);
res.redirect('/dashboard');
});The state attack, spelled out
The attack works because the callback is an unauthenticated GET that changes state. Every rule about CSRF applies to it, and state is how the spec answers that.
# WITHOUT state — "login CSRF" / account linking attack
1. Attacker starts a login at your app with THEIR OWN Google account
and stops at the callback, capturing:
https://abc.com/auth/callback?code=ATTACKER_CODE
2. Attacker gets the victim to visit that URL (email, image tag, redirect).
3. Your app exchanges ATTACKER_CODE and links the VICTIM's session to the
ATTACKER's Google identity.
4. The victim now uploads documents, saves cards, writes notes...
into an account the attacker can log into at will.
# WITH state
2'. The victim's browser has no oauth_tx cookie, or it holds a different
state value → mismatch → 400, flow aborted.
# state is not a nicety. It is the CSRF token of the OAuth callback.
Discussion