Lesson 04 of 04
OAuth without the mysticism
The authorization code flow in the order it actually happens, plus the two parameters people leave out.
OutcomesAfter this you will be able to
- Walk the authorization code flow step by step
- Explain what `state` and PKCE each defend against
- Handle the account-linking case without opening a takeover hole
01The flow, in order
- You redirect the user to the provider with your client id, a redirect URI, requested scopes, a random `state`, and a PKCE challenge.
- The user authenticates with the provider. You never see their password — that is the entire point.
- The provider redirects back to your registered URI with a short-lived `code` and your `state` echoed back.
- You verify `state` matches what you stored. If not, stop.
- Your server exchanges the code for tokens, over HTTPS, using your client secret and PKCE verifier. This happens server-to-server; the code alone is not enough.
- You fetch the user profile, find or create the local account, and issue your own session.
02state and PKCE are not optional
`state` is a random value you generate, store server-side (or in a signed cookie), and compare on return. Without it, an attacker can feed you a code obtained in their own session and get their account linked to your logged-in user — login CSRF.
PKCE defends the code itself. You send `code_challenge` (a SHA-256 of a random verifier) up front, and the verifier at exchange time. If the code leaks in a redirect chain, a referrer header, or a browser log, it is useless without the verifier. Originally a mobile concern; now recommended for every client type, including server-side ones.
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const challenge = base64url(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)),
);
// store verifier + state server-side against this browser, then redirect
const url = new URL('https://provider.example/authorize');
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', env.CLIENT_ID);
url.searchParams.set('redirect_uri', env.REDIRECT_URI);
url.searchParams.set('scope', 'openid email profile');
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');03The account-linking trap
Here is the bug that turns social login into account takeover. A provider returns an email address. You look up a local user with that email and log them in. If the provider did not verify the address, an attacker registers `victim@example.com` there, signs in through your app, and takes over the existing account.
- Check the `email_verified` claim. If it is absent or false, do not auto-link — ever.
- Key accounts on `(provider, provider_user_id)`, not on email. Email is mutable and reusable; the subject id is not.
- Link a second provider only from inside an already-authenticated session, as a deliberate action.
- Registering a fresh account with an email that already exists locally is a confirmation flow, not a silent merge.
Exercise
Trace a real handshake
Add a social login to a throwaway app and watch the whole exchange in the network tab with "preserve log" on. Find the `state` parameter going out and coming back. Then tamper with it on return and confirm your app rejects the callback. If it does not, that is the login-CSRF hole.