Sometimes the right answer is not to authenticate the user at all, but to ask somebody who already has.
Every lesson so far concerned credentials your site owns. This one is about a user who already has an account elsewhere and would rather not make another. If you have arrived here directly, the ideas you need are that a JWT is a signed and readable token whose claims must be checked rather than trusted, and that a redirect carrying a secret in its URL is a credential in transit.
Four roles, and what each is trusted with
OAuth 2.0 names four parties, and most confusion about the protocol comes from blurring them.
The resource owner is the user. They own the data and they are the only party who can grant access to it.
The client is your application. It wants something, and the entire design treats it as untrusted: it never sees the user's password, it receives only what was granted, and the protocol assumes it may be buggy or hostile.
The authorization server is the provider that authenticates the user and issues tokens. Google's or GitHub's login pages are this. It is the only party that ever sees the password.
The resource server holds the data and accepts access tokens. Often the same organisation as the authorization server, and logically separate.
The problem this solves is worth stating in its original form, because it explains the shape. Before OAuth, letting a service read your mail meant giving it your mail password: full access, forever, revocable only by changing the password, and revoking it broke everything else you had done the same way. OAuth replaces that with a token that is scoped, expiring and individually revocable. Everything awkward about the protocol follows from never letting the client see the password.
Note what OAuth 2.0 is not. It is an authorization framework: it produces a token saying what the bearer may do. It says nothing about who anybody is. Using it for login means adding a layer that does, which is what OpenID Connect is, and the sixth section is about the attacks that come from missing this distinction.
The authorization code flow
One flow matters. The others are deprecated, and RFC 9700, the current security best current practice, says so.
Step one, the client redirects the user's browser to the authorization server.
https://accounts.example.com/authorize
?response_type=code
&client_id=abc123
&redirect_uri=https://app.example.com/callback
&scope=openid%20email
&state=BqVc8Kk1sQ2wLh0nZbTt5Q
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256Step two, the user authenticates and consents, entirely at the authorization server. Your application sees none of it.
Step three, the authorization server redirects back to the registered redirect_uri with ?code=SplxlOBeZQQ&state=BqVc8Kk1sQ2wLh0nZbTt5Q.
Step four, the client exchanges the code in a direct back-channel POST, server to server, which the browser never sees:
const res = await fetch("https://accounts.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: "abc123",
client_secret: SECRET, // confidential clients only
code_verifier: verifier
})
});
const { access_token, refresh_token, id_token } = await res.json();The reason for the two steps is that the front channel, the browser redirect, is visible: URLs land in browser history, in server logs, in Referer headers. So the front channel carries only a short-lived, single-use code that is worthless without the back-channel exchange, and the tokens themselves travel over a direct TLS connection between two servers.
This is also why the implicit flow, which returned the access token straight in the redirect fragment, is dead. It put the token in the URL bar for the sake of clients that could not keep a secret, and PKCE solved that problem properly.
PKCE, and the code interception it stops
The code in the redirect is a bearer secret in a URL, and on a mobile device a malicious application could register the same custom URL scheme as the legitimate one and receive it. With a public client, one that ships no secret because anything in a browser bundle or a mobile binary is not secret, the attacker could then exchange the intercepted code for real tokens.
PKCE, RFC 7636, pronounced "pixy", fixes it by making the exchange require something only the original requester knows.
The client generates a code verifier, a random string of 43 to 128 characters from the unreserved URL set. Thirty-two random bytes base64url encoded gives exactly 43 characters and 256 bits, which is the sensible construction. It then sends only the code challenge, the base64url of the SHA-256 of the verifier, with the authorization request. At the token exchange it sends the verifier itself, and the authorization server hashes it and compares.
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
const verifier = bytes.toBase64({ alphabet: "base64url" });
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
const challenge = new Uint8Array(digest).toBase64({ alphabet: "base64url" });An attacker who intercepts the code has the challenge, from the URL they were watching, and cannot invert SHA-256 to get the verifier, so the exchange fails. PKCE is required for public clients and recommended for all of them, including confidential ones, because it also stops authorization code injection, where an attacker gets a victim to complete a flow using a code the attacker obtained.
Example. RFC 7636 gives the verifier dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk. Compute the challenge and check the length of both.
The verifier is 43 characters, the minimum the specification allows and the natural length of 32 base64url-encoded bytes. SHA-256 of the ASCII bytes of that string, base64url encoded without padding, is E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM, which is also 43 characters, since SHA-256 output is likewise 32 bytes. Both are 256 bits. Run the snippet above against this pair before trusting your implementation, exactly as with the TOTP vectors.
Now you. PKCE permits code_challenge_method=plain, where the challenge is the verifier unchanged. When is that safe, and what should a server do about it?
Answer
Almost never, and it exists only for clients that genuinely cannot compute SHA-256. With plain the challenge in the authorization URL is the verifier, so an attacker who can see the front channel, which is the exact attacker PKCE was written for, reads it and completes the exchange. It provides nothing against interception, only against an attacker who sees the code and not the authorization request. An authorization server should reject plain outright, and a client should never offer it. RFC 9700 says S256 is required where the client can support it, and every JavaScript runtime can.
Securing the round trip: state and the redirect URI
state is an opaque value the client generates, sends with the authorization request, and checks on the way back. It is CSRF protection for the callback, and the attack it stops is worth spelling out because it runs backwards from the usual one.
The attacker starts a legitimate OAuth flow with their own account at the provider, stops at the point where they hold a valid authorization code, and then causes the victim's browser to visit https://app.example.com/callback?code=<attacker's code>. If the client accepts it, the victim's session at your application is now linked to the attacker's provider account. Anything the victim subsequently saves, uploads or pays for lands in an account the attacker controls, and the attacker signs in through the provider whenever they like.
state stops it because the value is generated per attempt, bound to the victim's browser session, and checked on return. The attacker's crafted callback carries either no state or one from their own flow, and neither matches what the victim's session stored. Generate 32 random bytes, store it server side against the session, compare on return, and consume it.
A closely related attack is the mix-up, where a client supporting several providers is tricked into sending a code issued by one provider to another's token endpoint, potentially leaking it to an attacker-controlled authorization server. The defence is to record which provider each flow was started with, keyed by state, and to check the iss parameter that modern authorization servers return with the code.
The other half of the round trip is where the code is delivered. The redirect_uri is that address, so control of it is control of the code. The rule in RFC 9700 is unambiguous: the authorization server must compare the requested URI against the registered ones by exact string comparison, with no pattern matching, no wildcards, no prefix rules, and no ignoring of the query string.
Every relaxation of that has produced a real breach. Registering https://app.example.com/* and allowing a suffix means https://app.example.com/../evil or a path on the site that reflects a parameter becomes a delivery address. Allowing an arbitrary query string means ?redirect_uri=https://app.example.com/callback?next=https://evil.com may be forwarded onward by your own callback handler. Allowing subdomains means any subdomain takeover is an account takeover.
Then there is your own code. If your callback accepts a next or returnTo parameter and redirects to it after signing the user in, you have an open redirect, and an open redirect on a registered callback path is a code delivery mechanism for anyone who can put a URL in front of a user. Validate any post-login destination against an allowlist, or accept only paths beginning with a single / and reject anything containing // or a scheme.
Example. A provider allows a registered redirect_uri of https://app.example.com/callback and the client's callback reads a next parameter and redirects to it unchecked. Show how an attacker obtains an authorization code.
They construct an authorization URL with redirect_uri=https://app.example.com/callback exactly as registered, so the provider is satisfied, and add next=https://evil.com/collect so it survives into the callback. The victim clicks, authenticates at the provider, and is redirected to https://app.example.com/callback?code=SplxlOBeZQQ&state=...&next=https://evil.com/collect. The callback handler signs them in and then issues a redirect to https://evil.com/collect, and the browser attaches the full previous URL, code included, in the Referer header. The attacker reads the code from their own access log. Exact URI matching at the provider did not help, because the exploited redirect was the client's own.
Now you. Give two fixes for that, and say which one you would ship first.
Answer
First and immediately: validate next against an allowlist, or accept only a relative path that starts with a single / and contains no scheme and no //, which blocks both absolute URLs and protocol-relative ones. Second and structurally: do not carry the destination in the callback URL at all. Store it server side against the state value when the flow starts and read it back on return, so nothing an attacker can influence appears in the callback URL. Ship the validation first because it is a few lines and closes the hole today, then move to the state-keyed store, which removes the whole class rather than one instance of it.
Access token against ID token
Two tokens come back and they are for different audiences. Getting this wrong is the most common security bug in "Sign in with X".
The access token is a credential for the resource server. It is meant to be opaque to your application: you attach it to API calls and you do not inspect it. Its audience is the API, and it says what the bearer may do.
The ID token is a statement to your application about who the user is. It is a JWT, its audience is your client_id, it is signed by the authorization server, and it exists only because OpenID Connect added it. Its audience is you.
The dangerous pattern is using an access token as proof of identity. A client receives an access token, calls the provider's profile endpoint with it, gets back {"id": "12345", "email": "[email protected]"}, and signs the user in as 12345. The attack: a malicious client builds their own unrelated application, persuades the victim to authorise it, and receives an access token for the victim's account at the provider. They then present that access token to your application, which dutifully calls the profile endpoint, receives the victim's identity, and signs them in as the victim. Your application never checked who the token was issued to, because an access token carries no audience your code inspected.
This is known as the confused deputy, and the fix is the ID token, whose aud claim names your client and whose signature you verify. Validate it properly, every time:
Check the signature against the provider's published key set, found through the discovery document at /.well-known/openid-configuration, selecting the key by kid. Check iss equals the provider's issuer exactly. Check aud contains your client_id. Check exp has not passed, remembering it is in seconds. Check nonce matches the one you sent, which is the ID token's own replay defence and is separate from state. Only then read sub.
Example. A team accepts an access token posted from their mobile app, calls Google's userinfo endpoint with it, and signs the user in as whatever email comes back. What is the flaw and what breaks?
Any access token for that userinfo scope works, whoever issued it and whoever it was issued to. An attacker registers their own application with Google, gets a victim to authorise it under any pretext, receives an access token for the victim, and posts it to the team's endpoint. The userinfo call succeeds, returns the victim's email, and the attacker is signed in as the victim. Nothing about the token said it was minted for this application, and the code never asked. The fix is to run the authorization code flow with PKCE and validate an ID token whose aud is this application's own client ID.
Now you. After validating the ID token, the team looks the user up by the email claim and links them to an existing account with that address. What can go wrong?
Answer
Two things. Some providers issue an email claim that has never been verified, so an attacker sets their profile email at that provider to [email protected] and, if email_verified is not checked, takes over the matching local account. And email addresses change: a user who updates their address at the provider becomes a stranger to you, or worse, inherits an account belonging to whoever now holds their old address. The stable identifier is the pair of iss and sub, which the provider guarantees is unique and permanent for that user. Link on that, treat email as a display attribute, and if you do link by email, require email_verified to be true and require the user to prove control of the local account as well.
What OpenID Connect adds
OpenID Connect is a thin layer on top of OAuth 2.0 that turns an authorization protocol into an authentication one. Four additions carry it.
The openid scope, which requests the whole layer. The ID token, described above. The nonce parameter, sent with the authorization request and echoed inside the ID token, binding it to the request that asked for it. And discovery, the document at /.well-known/openid-configuration naming the endpoints, the supported algorithms and the JWKS URL, so a client configures itself from one address rather than from documentation.
The userinfo endpoint returns claims for an access token, and it is a convenience rather than a source of identity. Standard scopes profile and email request the usual claims, and providers vary in what they actually return: ask for the least you need, since a consent screen listing many scopes lowers completion and gives you data to protect.
What delegation actually delegates
"Sign in with Google" moves real work off your plate, and it is worth being exact about which work.
Delegated: password storage, password hashing, breach checks, reset flows, second factor enrolment and verification, and much of the abuse handling around all of them. That is most of the first seven lessons of this course, and it is why federated login is often the right choice for a small team.
Not delegated: everything after the ID token is validated. You still mint a session and set a cookie, so the third lesson applies in full. You still face CSRF and XSS, so the fifth does. You still decide what the user may do once signed in, which is the last lesson. And you still need account recovery for the user who loses access to their provider account, which is the flow the eighth lesson called the hard part.
Newly acquired: a dependency. If the provider is down, your users cannot sign in. If they close the user's account, or the user does, that person loses access to yours. If they change their terms or their pricing, you comply. Supporting two providers plus a local password is the usual hedge, and it brings the account linking problem from the previous exercise: the same person arriving by two routes must land in one account, and the join must be on something they proved rather than something they typed.
So delegation is a genuine reduction in work and not an escape from the subject. What it hands you at the end is an identity, a validated sub you can trust. What it never hands you is permission.
From identity to permission
Every lesson in this course has been aimed at one question: is this really who they claim to be. Passwords, sessions, tokens, second factors, passkeys and now federation are six answers to it, and by this point the answer is as strong as the state of the art allows.
The question the user's next request asks is different. They are authenticated, beyond doubt, and they have asked to delete invoice 4192. Nothing established so far says whether they may.
That check is the one that gets skipped, it is the most common serious breach on the web, and it is where the course ends.