OwlTheEngineer
How It Actually WorksEpisode 04 · Backend

OAuth 2.0, actually explained

Three parties, one redirect, and a code traded for a token. The whole protocol is visible in your address bar, and nobody ever tells you to look.

Owl · 7 min read · 0 views
– comments
Ep 04 · Backend
60s VISUAL BREAKDOWN
Episode 04
Watch the fast visual breakdown or discuss on social:

You’ve clicked “Continue with…” a few hundred times. Somewhere in there a URL bar changed host, flashed something with ?code= on the end, and changed back. That two seconds is the entire protocol, and it’s the part every explainer skips in favour of a sequence diagram with six arrows on it.

Here’s the whole thing, in the order it happens.

The setup

This site wants the photos you keep on a photo site. Two companies, one account, and a problem: owltheengineer.com needs your photos, and your photos live behind your password on photos.com.

Three parties, not two — and holding it as two is where most OAuth answers go wrong:

PartySpec nameWho
Youresource ownerthe person who owns the account
The app that’s askingclientowltheengineer.com
The site that has your accountauthorization server + resource serverphotos.com

The spec splits that last one in two — the authorization server issues tokens, the resource server holds the data and checks them. They’re often the same company and sometimes the same box, but they’re different jobs, and the split matters the moment you have more than one API.

The obvious answer, and its price

Give it your password.

It works. It also gives that site everything you can do on your account — read, upload, delete, change your email, lock you out — with no way to take it back except changing your password, which breaks every other app you did the same thing to. And now your password is sitting in a second company’s database.

That’s the “password anti-pattern”, and OAuth exists to replace it. RFC 6749 opens by listing exactly these limitations.

What actually happens

1. The app sends you away

The app doesn’t ask for your password. It redirects your browser to the photo site:

GET https://photos.com/authorize
  ?response_type=code
  &client_id=owltheengineer
  &redirect_uri=https://owltheengineer.com/callback
  &scope=photos.read+prints.write
  &state=a7f3c19b2e
  &code_challenge=E9Melhoa2Ow...
  &code_challenge_method=S256

Look at the host. You are now on photos.com. That is the entire security model in one line: at the moment you type your password, you are not on the app’s website. It cannot read what you type because it isn’t the page you’re typing into.

The photo site logs you in — its own login form, its own password, its own 2FA — and then shows you what’s being asked for and by whom.

Those two lines on the consent screen are the scopes: photos.read and prints.write. Not “access to your account”, but two specific things. The list is what the app asked for; granting it is your decision.

3. You come back with a code

Press Allow, and the photo site redirects your browser back:

https://owltheengineer.com/callback?code=SplxlOBeZQ&state=a7f3c19b2e

That code is not a token. It’s a stub: short-lived (the spec says it must be, and recommends a maximum of ten minutes), single-use, and worth nothing to whoever finds it.

Which is the point, because it just travelled in a URL — and URLs get written down. Browser history, server access logs, the Referer header, the screenshot in the support ticket. Anything valuable put in a query string is a credential you have published.

4. The app trades the code, out of your sight

This is the half nobody draws, because you can’t watch it happen. The app’s server calls the photo site’s server directly:

POST https://photos.com/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=SplxlOBeZQ
&redirect_uri=https://owltheengineer.com/callback
&client_id=owltheengineer
&client_secret=<only the app's own server has this>
&code_verifier=dBjftJeZ4CVP...

Two channels, and the split is the design:

  • The front channel — steps 1–3 — goes through your browser, in the open, where you can see it. It carries nothing secret.
  • The back channel — step 4 — is server to server, over TLS, nowhere near your browser. It carries the secret.

A stolen code buys nothing, because redeeming it needs something the thief doesn’t have.

5. The token comes back

{
  "access_token": "2YotnFZFEjr1zCsicMWpAA",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
  "scope": "photos.read prints.write"
}

The app sends that access token with every request to the photo site’s API:

GET /v1/photos HTTP/1.1
Host: api.photos.com
Authorization: Bearer 2YotnFZFEjr1zCsicMWpAA

And the trade is done. They hold a token, not a key. It only covers the scopes you granted, it expires, and you can revoke it from the photo site’s “connected apps” page without touching your password.

The two parameters that aren’t optional

The video leaves both of these out — a ninety-second explanation has room for the shape or the parameters, not both. If you’re implementing this, they’re not extras.

state — the one that stops CSRF on the callback

Your callback URL is a public endpoint. Anyone can send a browser to it with a code on the end, including an attacker who has just started their own OAuth flow with their own account. If your server accepts it, you’ve now attached their photo account to your logged-in user, and everything the victim uploads goes to the attacker’s storage.

state fixes it: generate a random value, bind it to the user’s session before you redirect, and refuse the callback if what comes back doesn’t match. It’s four lines and it is the difference between an implementation and a vulnerability.

PKCE — proof you’re the app that started this

client_secret assumes the app can keep a secret. A server can. A single-page app or a mobile app cannot — ship a secret in JavaScript or in a binary and it isn’t a secret, it’s a string with extra steps.

PKCE (RFC 7636, “pixie”) replaces the stored secret with a one-time proof:

  1. Before redirecting, generate a random code_verifier and keep it locally.
  2. Send code_challenge = BASE64URL(SHA256(verifier)) with code_challenge_method=S256 on the /authorize request.
  3. Send the raw code_verifier on the /token request.

The authorization server hashes what you send and compares. A stolen code is useless without the verifier, which never went near the browser’s address bar.

It was written for public clients, and current guidance is to use it for all of them, confidential ones included — the OAuth 2.0 Security Best Current Practice says so, and OAuth 2.1, still a draft, folds it into the spec itself.

One more, while you’re here: redirect_uri must be pre-registered and matched exactly. Wildcard or prefix matching on your redirect URI is how codes get delivered to somebody else’s server.

OAuth is not a login protocol

This is the single most common error in the material this post is competing with, so:

OAuth 2.0 is authorization. It answers “is this app allowed to do this?” An access token says what may be done. It does not say who you are — it’s deliberately opaque to the client, and it may not even mention you.

OIDC is authentication. OpenID Connect is a thin layer on top of OAuth 2.0 that adds an ID token: a JWT, for the client, that says who signed in, who issued it, who it’s for, and when. openid in the scope list is what asks for it.

“Sign in with Google” is OIDC. The OAuth part underneath is what lets the app also read your calendar.

Why it matters in practice: if you authenticate users by taking an access token from a client and asking the provider “whose token is this?”, you’ve built the confused deputy. A token issued to some other app, for the same user, looks just as valid — so any app that user has ever authorised can sign in as them on yours. The ID token has an aud claim naming your client precisely so you can reject that, and validating aud and iss is not optional.

Which flow, in 2026

Your appFlow
Web app with a backendAuthorization code + PKCE
SPA, mobile, desktopAuthorization code + PKCE, no client secret
Machine to machine, no userClient credentials
TV, console, anything without a keyboardDevice authorization grant
Anything at allNot implicit, not password grant

The implicit grant returned the token straight in the URL fragment, which put a live credential in browser history. It’s discouraged by the security BCP and gone from the 2.1 draft. The password grant — the app collects your username and password and posts them — is the anti-pattern OAuth was written to end, wearing the protocol’s name.

Three mistakes I see constantly

  1. Skipping state because the flow works without it. It does work without it. That’s what makes it a bug you ship instead of a bug you notice.
  2. Using an access token as proof of identity. Wrong layer. Ask for openid, take the ID token, and validate aud and iss.
  3. Requesting every scope up front because it’s easier than a second consent later. Every scope you ask for is a reason to click Cancel, and a bigger blast radius on the day your token store leaks.

What to say when you’re asked

OAuth is delegated authorization between three parties: the user, the app that wants access, and the site that holds the account. The app redirects the user to that site, so the password is only ever typed on the site that already has it. The user consents to specific scopes and comes back with a short-lived, single-use authorization code in the URL. The app’s server then exchanges that code out of band — with a client secret, or a PKCE verifier — for an access token, which it sends as a bearer token on each API call. The result is that the app holds a scoped, expiring token it can be stripped of, instead of a password. And it’s authorization, not authentication — signing users in is OIDC, which adds an ID token on top.

The next question after that one is usually why does the access token expire in fifteen minutes, which is refresh tokens, and its own post.

SYSTEM DESIGN CHECK

Test Your Understanding

Quick architectural scenario challenge based on this breakdown.

SCENARIO

A 3rd-party payment gateway starts timing out with 504 errors during peak traffic.

Q1.How should a downstream dependency failure be handled to protect upstream service availability?

Comments –