Skip to content

8.4.3 — OAuth 2.0, End to End

A scheduling app wants to read your Google Calendar. In 2005 the way to do that was to type your Google password into the scheduling app, which stored it and logged in as you.

Everything about that is wrong. The app now has full access to your email, your files and your account recovery. You cannot revoke it without changing your password. Google cannot tell the app apart from you. And every such app is a place your password can leak from.

OAuth 2.0 exists to let one application act on your behalf at another, with limited access, without ever seeing your password, and revocably. Everything else in the specification is machinery for doing that securely across a browser redirect.

1. The four roles, and the one sentence that prevents most confusion

Resource owner — you. The person who owns the data.

Client — the application asking for access. The scheduling app.

Authorization server — the system that authenticates you and issues tokens. Google's login.

Resource server — the API holding the data. The Calendar API.

And the sentence: OAuth is an authorization framework, not an authentication protocol. It answers "may this application do this thing on this user's behalf", not "who is this user".

That distinction is not pedantry. An access token proves someone consented; it does not tell you who consented, and a token obtained by one application can be presented to another. Systems that logged users in by accepting an access token as proof of identity have been broken exactly this way. The fix is OpenID Connect (Chapter 8.4.4), which adds an identity layer on top. If you are implementing "log in with X", you want OIDC, and OAuth is the substrate underneath it.

2. The Authorization Code flow

This is the flow. The others are special cases.

browserclient (server)authorization server① "connect my calendar"② 302 → /authorize?client_id&redirect_uri&scope&state&code_challenge③ user authenticates and consents (front channel)④ 302 back to redirect_uri?code=…&state=…⑤ code lands at the client⑥ POST /token — code + client_secret + code_verifier⑦ access_token + refresh_token (back channel)
Steps 2–5 travel through the browser, where anything can be observed. Steps 6–7 are a direct server-to-server call. That split is the reason a code exists at all.
GET https://auth.example.com/authorize
  ?response_type=code
  &client_id=sched-app
  &redirect_uri=https://sched.example.com/callback
  &scope=calendar.read
  &state=9f2b1c…
  &code_challenge=E9Melhoa…&code_challenge_method=S256

The essential idea is the front channel / back channel split. The front channel is the browser: redirects, URLs, referrers, history, browser extensions — everything there is observable and modifiable. The back channel is a direct HTTPS call from the client's server to the authorization server, which nothing else can see.

So the front channel carries a authorization code, which is useless on its own. Exchanging it for a token requires the client's secret, or a PKCE verifier, over the back channel. Even if the code is stolen from a log or a browser history, it cannot be redeemed.

The code is single-use and short-lived — typically under a minute. An authorization server that sees the same code twice should revoke every token issued from it, because a second use means someone else has it.

3. PKCE, and exactly what it prevents

Public clients — mobile apps, desktop apps, single-page applications — cannot keep a secret. Anyone can decompile the binary or read the JavaScript. So the back channel's protection is gone: a stolen code can be exchanged by anyone.

The concrete attack on mobile: the app registers a custom URL scheme like myapp://callback. A malicious app registers the same scheme, and on some platforms it receives the redirect instead. It now holds a valid code and, with no client secret needed, exchanges it for a token.

PKCE (proof key for code exchange) closes this with one hash.

  1. Before starting, the client generates a random code verifier — 43 to 128 characters from a CSPRNG.
  2. It sends code_challenge = BASE64URL(SHA256(verifier)) in the authorization request.
  3. The authorization server stores the challenge with the code.
  4. When exchanging the code, the client sends the verifier.
  5. The server hashes it and compares. No match, no token.

The attacker who intercepted the code never saw the verifier — it never left the legitimate client — and cannot compute it from the challenge, because SHA-256 is one-way.

Use S256, never plain. The plain method sends the verifier as the challenge, which defeats the entire mechanism if the authorization request is observable.

PKCE is now recommended for every client, including confidential ones with a client secret. OAuth 2.1 makes it mandatory. It costs nothing and defends against code injection even where a secret exists.

4. state and redirect_uri: the two parameters that get systems breached

state is CSRF protection for the callback. Without it, an attacker starts an authorization flow with their account, captures the resulting code, and tricks you into visiting https://sched.example.com/callback?code=<attacker's code>. Your client exchanges it and links the attacker's calendar account to your session — after which the attacker can read anything you subsequently save there.

The rule: generate a random state, bind it to the user's session, and reject any callback whose state does not match. With OIDC, the nonce claim serves the parallel purpose for the ID token.

redirect_uri must be matched exactly, string for string, against a pre-registered list. Every relaxation has caused breaches:

  • Wildcards (https://*.example.com/callback) — one compromised or user-controlled subdomain receives everyone's codes.
  • Prefix matchinghttps://example.com/callback also matches https://example.com/callback.evil.com under a naive check.
  • An open redirect anywhere on the registered host — the code arrives at your domain and is immediately bounced to the attacker's, with the code in the URL. This is why an unrelated open redirect on your marketing site is an OAuth vulnerability.

5. The other flows, and the two that died

Client credentials — no user at all. A service authenticates as itself with its client id and secret and gets a token for its own access.

POST /token
grant_type=client_credentials&client_id=reports&client_secret=…&scope=orders.read

This is the standard machine-to-machine flow, and Chapter 8.6.2 compares it with mTLS and API keys.

Device authorization flow — for inputs that cannot host a browser: a television, a command-line tool, a device with no keyboard. The device shows a short code and a URL; you open it on your phone and approve; the device polls the token endpoint until you do. Every "go to example.com/activate and enter ABCD-EFGH" screen is this flow.

Refresh token grant — exchange a refresh token for a new access token. Chapter 8.4.2 covers rotation and reuse detection.

Implicit flow — dead. It returned the access token directly in the URL fragment, because early browsers had no cross-origin request support. That put a live credential in the browser history, in the Referer header and in any logging that captured the URL, with no refresh token and no way to authenticate the client. Replaced entirely by authorization code plus PKCE, which single-page applications can now use because CORS exists.

Resource owner password credentials — dead. The user typed their password into the client, which forwarded it. That is the very anti-pattern OAuth was created to remove, it cannot support multi-factor authentication, and it trains users to type their credentials into third-party interfaces. It is removed in OAuth 2.1. It survives in legacy migrations and should be on a schedule to leave.

A confidential client can keep a secret — a server-side application. A public client cannot — mobile, desktop, browser-based. The classification decides whether a client secret means anything, and public clients therefore depend on PKCE and exact redirect matching.

Scopes are the permissions requested, and they belong to the resource server's vocabulary: calendar.read, orders.write. Two things about them are routinely misunderstood.

A scope is a ceiling, not a grant. orders.write means the token may be used for order writes; whether this user may write this order is your application's authorisation decision (Chapter 8.4.10). Treating a scope as a permission is a real privilege-escalation bug — every user with the scope can then act on every object.

Ask for the minimum, and ask late. Incremental authorization requests extra scopes when the feature is first used, rather than everything at signup. Consent screens listing broad access at first contact are abandoned, and users who click through them have not consented to anything meaningful.

Dynamic client registration lets a client register itself with an authorization server through an API rather than a human filling in a form. It matters for platforms with many clients, and it needs its own authorisation or anyone can register a client.

7. Logout, token exchange, and OAuth 2.1

Logout is genuinely awkward, because there are at least three sessions: the client's, the authorization server's, and possibly an upstream identity provider's.

Front-channel logout — the browser is sent to each participant's logout URL, usually via hidden iframes. Simple, and increasingly broken by third-party cookie restrictions (Chapter 5.6.3).

Back-channel logout — the authorization server calls each client's logout endpoint directly with a signed token. Reliable, works without a browser, and requires clients to implement an endpoint. This is the direction everything is moving.

Token exchange (RFC 8693) solves a real problem in service estates: service A holds a token for the user, calls service B, and B must call C as the user rather than as itself. A naive design forwards the original token, which gives B a credential it can use anywhere. Token exchange trades it for a new token scoped to exactly the next hop.

OAuth 2.1 consolidates two decades of practice: PKCE required for all clients, implicit and password grants removed, exact redirect URI matching, and refresh token rotation or sender-constraining for public clients. If you learn one version, learn this one — it is the set of defaults you should be applying anyway.

8. The vulnerabilities to check for

Missing or unbound state — session fixation on the callback, as in section 4.

Loose redirect_uri matching — code theft. Check for wildcards, prefix matching and open redirects on the registered host.

Codes not single-use — a replayed code issues a second token.

Tokens in URLs — logs, history, Referer.

Unvalidated aud on the resource server — a token minted for another service is accepted (Chapter 8.4.2).

The mix-up attack — a client that supports several authorization servers is tricked into sending a code issued by one to another's token endpoint. The defence is checking the iss parameter returned with the callback and tracking which server the flow was started with.

Overly broad scopes requested once and retained forever, and refresh tokens without rotation, which turn one theft into permanent access.

What the interviewer will push on

"What problem does OAuth solve?" Delegated access: an application acts on your behalf at another service with limited, revocable scope, without ever seeing your password. Then state the correction that half of candidates miss — it is authorization, not authentication, and using an access token as proof of identity is a real vulnerability that OIDC exists to fix.

"Walk me through the authorization code flow." Redirect to /authorize, user authenticates and consents, code returns to the registered redirect URI, client exchanges it at /token over the back channel. The tell is explaining why the code exists: the front channel is observable, so it carries something useless without the back-channel exchange.

"What does PKCE prevent?" A stolen authorization code being redeemed by an attacker — most concretely, a malicious mobile app registering the same custom URL scheme and receiving the redirect. The verifier never leaves the legitimate client and cannot be derived from the challenge. Add that S256 is required and plain defeats it, and that OAuth 2.1 makes PKCE mandatory even for confidential clients.

"What is state for?" CSRF protection on the callback. Without it an attacker injects their own code so their account is linked to your session. It must be random, bound to the session, and rejected on mismatch — generating it and never checking it is the common half-implementation.

"Why did the implicit flow die?" It returned a live access token in the URL fragment — into browser history, Referer and logs — with no client authentication and no refresh token. It existed only because browsers lacked cross-origin requests; CORS removed the reason, and code plus PKCE replaced it.

"A client asks for a wildcard redirect URI. What do you say?" No. One compromised or user-controlled subdomain then receives every user's authorization code. Exact string matching against a registered list, and audit the registered hosts for open redirects, because an open redirect on your marketing site is an OAuth vulnerability.

One thing to volunteer: point out that a scope is a ceiling, not a permission. A token with orders.write says the application may write orders; whether this user may write this order is still your application's decision. Systems that treat scope as authorisation let any user with the scope act on any object, and that mistake is common in exactly the codebases that got everything else right.

Recall

  • Four roles: resource owner, client, authorization server, resource server. OAuth is authorization, not authentication — an access token proves consent, not identity, which is why OIDC exists.
  • The authorization code flow exists because of the front channel / back channel split: the browser carries a single-use, short-lived code that is useless without a back-channel exchange.
  • PKCE: random verifier, send SHA256(verifier) as the challenge, present the verifier at exchange. It stops a stolen code being redeemed — the malicious app registering the same URL scheme. Use S256; OAuth 2.1 requires PKCE for all clients.
  • state binds the callback to the session and stops an attacker injecting their own code into your session. redirect_uri must match exactly — wildcards, prefix matching and any open redirect on the registered host all leak codes.
  • Other flows: client credentials (machine to machine), device authorization (the "enter this code" screen), refresh grant. Implicit is dead (token in the URL fragment) and password grant is dead (it is the anti-pattern OAuth replaced); both are removed in OAuth 2.1.
  • Confidential clients can keep a secret; public ones cannot, so they depend entirely on PKCE and exact redirect matching.
  • A scope is a ceiling, not a permission. Whether this user may act on this object is still your application's authorisation decision. Ask for the minimum, and use incremental authorization.
  • Back-channel logout is replacing front-channel, which third-party cookie restrictions break. Token exchange (RFC 8693) gives a downstream service a narrowly scoped token instead of forwarding the original.

Self-test: Why is the code useless to an attacker who steals it? · What exactly does the malicious-app attack look like on mobile, and how does PKCE stop it? · What goes wrong if state is generated but never checked? · Why is an open redirect on your website an OAuth vulnerability? · Why did implicit exist and why did it stop? · What breaks when a team treats a scope as a permission?

Next: 8.4.4 adds the identity layer OAuth deliberately left out — OpenID Connect, the difference between an ID token and an access token, and the discovery machinery that makes "log in with anything" a configuration file.