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.
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:
| Party | Spec name | Who |
|---|---|---|
| You | resource owner | the person who owns the account |
| The app that’s asking | client | owltheengineer.com |
| The site that has your account | authorization server + resource server | photos.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.
2. You authenticate and consent
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:
- Before redirecting, generate a random
code_verifierand keep it locally. - Send
code_challenge = BASE64URL(SHA256(verifier))withcode_challenge_method=S256on the/authorizerequest. - Send the raw
code_verifieron the/tokenrequest.
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 app | Flow |
|---|---|
| Web app with a backend | Authorization code + PKCE |
| SPA, mobile, desktop | Authorization code + PKCE, no client secret |
| Machine to machine, no user | Client credentials |
| TV, console, anything without a keyboard | Device authorization grant |
| Anything at all | Not 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
- Skipping
statebecause 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. - Using an access token as proof of identity. Wrong layer. Ask for
openid, take the ID token, and validateaudandiss. - 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.
Test Your Understanding
Quick architectural scenario challenge based on this breakdown.
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 –
Sign in to share your thoughts
Nothing yet. Be the first.