Start here: letting an app act as you, without your password#
TL;DRthe 30-second version
- OAuth 2.0 solves delegated access: you let one app reach your data at another service, without giving that app your password for the service.
- The naive fix — hand the app your password — is a disaster: it can now do everything you can, forever, and the only way to revoke it is to change your password (which breaks everything else too).
- Instead the app sends you to the service to log in and approve a narrow request. The service hands the app a scoped access token — permission to do just that one thing — and never reveals your password.
- The flow has four parties (you, the app, the service's login server, the service's API) and runs as a redirect to consent, a one-time code, then a back-channel exchange of that code for the token. PKCE ties the code to the app that started the flow, so a stolen code is useless.
- OAuth answers 'what may this app do' (authorization). It does not reliably answer 'who is this user' (authentication). Using a raw OAuth access token as proof of identity is the classic mistake — that job belongs to OpenID Connect's ID token.
You find a website that prints photo books from your Google Photos. To do its job it needs to read your photos. The obvious way to let it is to type your Google email and password into its login box. Stop and think about what that actually gives away.
Your Google password isn't a key to your photos. It's the key to everything: Gmail, Drive, your calendar, your saved payment methods, and the password itself. Hand it to the print shop and you've handed over all of it, not the one folder you meant to share. There's no way to say 'photos only.' There's no way to say 'just for today.' And to take the access back, your single option is to change your Google password, which logs you out of every other app and device at the same time. One narrow favour, and the only tool you have is a nuclear one.
The four parties in every OAuth flow#
Before the flow makes sense, name the cast. Every OAuth exchange has exactly four parties, and once you can point at each one, the redirects stop looking like magic. Keep the photo example in mind as we name them.
- Resource owner — you. You own the data (your photos) and you're the only one who can grant access to it.
- Client — the app asking for access, the photo-printing site. 'Client' here means the third-party app, not a browser. It's the party that ends up holding the token and using it.
- Authorization server — the part of Google that logs you in and runs the consent screen. It's the only party that ever checks your password, and it's the one that issues tokens.
- Resource server — the API that actually holds your data, the Google Photos API. It doesn't know your password; it just accepts a valid token and serves the data the token is allowed to reach.
Notice the split inside Google: the authorization server (login and consent) and the resource server (the photos API) are separate jobs. The authorization server is the only one that touches your password. It vouches for you by issuing a token, and the resource server trusts that token instead of ever asking who you are. That separation is what lets the print shop get at your photos while your password stays with Google alone.
The authorization code flow, step by step#
Here's the whole exchange, the one OAuth calls the authorization code flow. It's the default and most secure OAuth flow, and it's what happens every time you click 'Connect your Google account' on some app. Follow it once and the rest of OAuth is detail.
Two details in that flow do the real work. The first is that you log in at Google, on a Google page, not on the print shop. Your password travels to the one party that already has it and nowhere else. The print shop watches you disappear to Google and come back approved; it never sees what you typed.
The second detail is stranger: Google doesn't hand the token back through your browser. It hands back a one-time authorization code — a short, single-use string that is worth nothing on its own. The app then makes a second call, directly to Google from its own server, and trades that code for the actual access token. Why the extra step? Because the trip back through your browser is exposed. The code rides in a redirect URL, which can land in browser history, server logs, or a referrer header. If the real token travelled that way, anyone who glimpsed the URL would have your token. The code is deliberately useless without the second, private exchange.
PKCE: stopping a stolen code#
The back-channel exchange has one built-in defence so far. When the app trades the code for a token, it also sends a client secret — a password the app registered with Google ahead of time. So even someone who steals the code can't exchange it; they'd need the secret too. That works for an app running on its own server, where a secret can stay hidden. It falls apart for a public client.
A public client is an app that can't keep a secret, because its code runs where the user (and an attacker) can read it: a mobile app on a phone, or a single-page app running entirely in the browser. Ship a client secret inside a mobile app and anyone can pull it out of the binary. So public clients run the flow with no secret. Now the only thing guarding the code exchange is the code itself, and on a shared device the code can be stolen. A malicious app on the same phone can register to catch the redirect and grab the code as it comes back. With no secret required, the attacker exchanges the stolen code for a token and walks off with your access.
PKCE (Proof Key for Code Exchange, say 'pixy') closes that hole without a stored secret. Instead of a fixed secret, the app invents a fresh random secret for each flow. At the very start, the app makes up a random string called the code verifier and keeps it in memory. It hashes the verifier with SHA-256 (a one-way function: easy to compute forwards, infeasible to reverse) to get the code challenge, and sends only the challenge in the first redirect. Google files the challenge away next to the code. Later, at the back-channel exchange, the app must present the original verifier. Google hashes it and checks that it matches the challenge it stored. Only the app that started the flow knows the verifier, so only that app can complete the exchange.
PredictA mobile app runs the flow with PKCE. An attacker intercepts the authorization code the instant it comes back. To trade that code for a token, Google's token endpoint demands the code verifier — 32 random bytes whose SHA-256 hash the app sent earlier as the challenge. The attacker has the code and the challenge, but not the verifier. Roughly how many verifiers must they try, and what does that mean for the attack?
Hint: SHA-256 is one-way and the verifier is 256 bits of randomness — can the challenge be reversed, or only guessed?
The verifier is 32 random bytes, which is 256 bits of entropy — about 2^256, or 1.2×10^77, possible values. SHA-256 is one-way, so the challenge the attacker captured gives no shortcut back to the verifier; the only route is to guess. And 10^77 guesses is not a search anyone finishes before the sun burns out. So the intercepted code is worthless without the verifier, and the verifier never left the app's memory. That is the whole point of PKCE: it binds the authorization code to the one app that started the flow, so stealing the code in transit buys the attacker nothing.
PKCE started life as a fix just for public clients, but current guidance is to use it for every OAuth client, secret or not. It's a cheap, universal guard against code theft, so there's no reason to leave it off.
Scopes and the token itself#
Go back to the consent screen — the 'Print shop wants: read your photos' box. That specific permission is a scope: a named slice of access the app is asking for. Google publishes scopes like photoslibrary.readonly (read photos) or gmail.send (send mail as you). The app lists the scopes it wants in the first redirect, the consent screen shows you exactly those, and the token it gets back is stamped with only the scopes you approved. When the app calls the photos API, the resource server checks the token's scopes and refuses anything outside them. Ask for read-only photos and the same token can't touch your Gmail, even if the app tries.
This is least privilege in action: grant the narrowest set of powers that does the job, so a leak or a bug can only do that much damage. A good app asks for the fewest scopes it needs; a consent screen demanding far more than the task requires is a warning sign.
What the app actually holds is an access token. This is the same access token from /auth — a bearer token, meaning whoever holds it is treated as authorized, no questions asked. That's why it's short-lived and why it must ride over TLS (see /tls/sim): a bearer token sent over plain HTTP can be copied off the wire and replayed by anyone. Alongside it, OAuth issues a refresh token — the long-lived, revocable token the app uses to get a fresh access token when the short one expires, without dragging you back through the login screen. The mechanics of access-versus-refresh tokens are covered in /auth; here it's enough that OAuth is the protocol that hands them across company lines, from Google to the print shop.
The trap: OAuth is not a login system#
Now the distinction that catches almost everyone. Everything so far was about access: letting the print shop reach your photos. But you've also seen 'Sign in with Google' buttons that just log you in, with no data-sharing in sight. It's tempting to build that with the same access token, and it's a real security bug. Here's the reasoning, then the fix.
An OAuth access token is proof of permission, not proof of identity. It says 'the holder may read some photos.' It does not reliably say 'the holder is Alice,' and crucially it was not issued to your app as a statement about who the user is. So a login built on it works like this: the user connects Google, your app gets an access token, your app calls Google's API with it, sees the account is Alice's, and logs the user in as Alice. The hole is that the access token is a bearer token — anyone holding a valid one can present it.
That opens the token substitution attack. Suppose a malicious app also, legitimately, got an access token for Alice — she used that app too, and granted it access. That app now holds a valid Google access token for Alice. It replays that token to your app's login. Your app does what it always does: calls Google with the token, sees Alice, and logs the attacker in as Alice. Your app had no way to tell that the token was minted for a different app, because a raw access token carries no reliable 'who this was issued to' that your app checks. It was never meant to answer 'who is signing in here.'
So the two tokens answer two different questions, and this is the line to hold onto. The access token is authorization — what the holder may do at the resource server. The ID token is authentication — who the user is, proven to the specific app that asked. 'Sign in with Google' is OIDC, and it works because of the ID token. If you ever find yourself using a raw OAuth access token to decide who a user is, you've reached for the authorization tool to answer an authentication question, and that's the bug.
What it costs#
OAuth buys you delegated access without password-sharing, and it isn't free. The price is moving parts. A simple login is one form post; an OAuth flow is a redirect out to another company's server, a consent screen, a callback, and a back-channel exchange — several round-trips, any of which can fail. Your app now depends on the authorization server being up: if Google's login is down, nobody can connect.
You also inherit token handling. Access tokens expire, so you store refresh tokens and run the refresh exchange when they do. Every token is a bearer token, so the entire scheme rests on TLS (see /tls/sim); drop to plain HTTP anywhere and a copied token is game over. And the flow has sharp edges an attacker probes — the code interception PKCE guards, and a cross-site request forgery on the callback that a state parameter guards (both in the under-the-hood beats below). None of this is hard once, but it's why the near-universal advice is to use a provider's SDK rather than hand-rolling the flow.
Under the hood
Four pieces the open path skips, for the reader who wants the mechanism whole.
Go deeperThe implicit flow, and why it's dead
Before PKCE existed, public clients had a problem: no secret to protect the code exchange. The early answer was the implicit flow, which skipped the code entirely and returned the access token straight in the redirect, in the URL fragment, through the front channel. That put the real token where the code interception problem lives — browser history, referrer headers, any script on the page — and it gave the authorization server no way to confirm which app was receiving the token. The OAuth 2.0 Security Best Current Practice (RFC 9700) now advises against it outright. The replacement is the authorization code flow with PKCE, for public clients and confidential clients (ones that can safely hold a secret, like a server-side app) alike.
Go deeperThe state parameter (CSRF on the callback)
The callback — Google redirecting back to your app with a code — is a request into your app that an attacker can try to forge, a cross-site request forgery (CSRF: tricking a logged-in user's browser into making a request they didn't intend). An attacker could splice their own authorization code into your callback URL and get your account linked to their identity. The defence is the state parameter: the app generates a random value at the start, sends it in the first redirect, and Google echoes it back on the callback. The app checks that the returned state matches the one it sent. A forged callback won't carry the right value, so it's rejected.
Go deeperOpaque tokens and introspection
An access token can be a self-describing JWT the resource server verifies on its own, or an opaque token — a random string that means nothing without asking the authorization server about it. For opaque tokens, the resource server calls a token introspection endpoint (RFC 7662): it hands over the token and asks 'is this still valid, whose is it, and what scopes does it carry?' This is the revocable counterpart to a self-verifying JWT. It costs a network call per check, but the authorization server stays the single source of truth, so revoking a token takes effect immediately instead of waiting for expiry.
Go deeperJWKS and key rotation
When ID tokens (and JWT access tokens) are signed, the app needs the authorization server's public key to verify the signature. Rather than baking the key into the app, the authorization server publishes its public keys at a JWKS (JSON Web Key Set) endpoint, and the app fetches them from there. Each key has an id, and every token names the key that signed it in a kid header field. So the server can rotate its signing keys — publish a new one, retire the old — and apps keep verifying without a redeploy: they just fetch the current set and match on the kid. Key rotation limits the damage if a signing key is ever exposed.
When to reach for which
OAuth and OIDC answer different needs, and the wrong one is a real mistake. A few rules that hold up.
- You need delegated access — an app acting on a user's data at another service (read their photos, post to their calendar, list their repos): OAuth 2.0, authorization code flow with PKCE. This is the photo-printing case.
- You need to log a user in — 'Sign in with Google/Apple/GitHub' with no data access required: OpenID Connect, and read the identity from the ID token. Do not authenticate a user from a raw access token.
- You're building either flow yourself, by hand: don't. Use a provider or a vetted library (Auth0, Okta, Google's SDKs). The flows are subtle and the attacks — code interception, token substitution, callback CSRF — are easy to get wrong and hard to notice.
- Public client (mobile or single-page app): always PKCE, never the implicit flow. The implicit flow is deprecated for exactly the token-exposure reason above.
Access token vs ID token, side by side
The two tokens look similar and do opposite jobs. Confusing them is the source of the identity bug, so here they are next to each other.
| Access token (OAuth) | ID token (OIDC) | |
|---|---|---|
| Answers | What may the holder do? | Who is the user? |
| Purpose | Authorization | Authentication |
| Issued for | The resource server / API | The specific client app (aud claim) |
| Who reads it | The resource server, on each API call | The client app, once at login |
| Format | Opaque string or JWT | Always a signed JWT |
| Never use it to… | Identify the user | Call a resource API |
In the wild
- Google runs OIDC on top of OAuth 2.0: 'Sign in with Google' reads identity from the ID token, while access tokens grant scoped reach into Gmail, Drive, and the Photos API. The authorization server and the resource APIs are the split described above, made concrete.
- GitHub's OAuth apps are the delegated-access case in its purest form: a CI service or a code-review bot asks for scopes like repo or read:org, and acts on your repositories with a scoped token — never your GitHub password.
- Auth0, Okta, and Clerk are managed authorization servers: they run login and consent, issue the tokens, publish the JWKS endpoint, and hand you SDKs, so an app gets OAuth/OIDC without building the flow or the attacks-avoided list itself.
- Slack, Stripe, and Zoom 'Connect' buttons are OAuth authorization code grants: you approve a scoped grant once, and the app holds a refresh token to keep acting on your account until you revoke it.
Pitfalls & gotchas
Can I just use the access token to log the user in?
No — that's the classic mistake. An access token proves permission, not identity, and it's a bearer token that could have been issued to a different app entirely. Building login on it opens the token substitution attack: a token minted for app B gets replayed to your app, and your app logs the attacker in as the victim. Use OpenID Connect and read identity from the ID token, which is issued for your app specifically (the aud claim) and verified by signature. Access token for calling APIs; ID token for knowing who signed in.
Is OAuth authentication or authorization?
OAuth 2.0 is authorization — delegated access to resources. It says what an app may do on your behalf, not who you are. The confusion comes from 'Sign in with Google,' which feels like authentication but is really OpenID Connect (an identity layer) running on top of OAuth. If a page says 'OAuth login' and means real user authentication, it almost always means OIDC underneath.
Why not just return the token in the first redirect and skip the code?
That was the implicit flow, and it's deprecated. The first redirect goes through the front channel — your browser, browser history, referrer headers, any script on the page — so a token returned there is exposed to anyone who can see the URL, and the authorization server can't confirm which app received it. The authorization code flow returns a useless one-time code through the front channel and delivers the real token only over the private back channel. Use it with PKCE for every client.
Do I still need PKCE if my app has a client secret?
Current guidance says yes, use PKCE everywhere. The client secret protects the back-channel exchange, but PKCE additionally binds the authorization code to the exact flow that started it, closing code-interception attacks that a secret alone doesn't cover. It's cheap and adds a layer, so the recommendation is to apply it to confidential clients too, not just public ones.
QuizYour app lets users 'Sign in with Google.' A security reviewer asks how you verify the user's identity on each login. Which answer is correct — and which one is the token substitution bug?
- Call Google's API with the access token, see whose account it is, and log that user in.
- Read the user's identity from the OIDC ID token, after verifying its signature and that the audience claim is your own client id.
- Trust whatever user id the app's front-end sends back after the redirect.
- Any of these — as long as Google returned a valid token, the user is authenticated.
Show answer
Read the user's identity from the OIDC ID token, after verifying its signature and that the audience claim is your own client id. — Only the ID token answers 'who is this user' safely. It's a signed JWT issued for your app specifically — you verify the signature and check that the aud claim names your client id, so a token minted for a different app is rejected. Choice 1 is the token substitution bug: an access token is a bearer token that proves permission, not identity, and it may have been issued to another app entirely, so an attacker can replay one and be logged in as the victim. Choice 3 trusts an unauthenticated value from the browser. Choice 4 is the whole misconception this page exists to kill.
In an interview
OAuth shows up in interviews as 'how does Sign in with Google work' or 'how would you let a third-party app access user data.' The strong answer walks the flow and names the OAuth-versus-OIDC line, instead of waving at 'we use OAuth.'
- Name the four parties (resource owner, client, authorization server, resource server) and walk the authorization code flow: redirect to consent, one-time code back, back-channel exchange for the token. Interviewers notice when you know why the code comes back and not the token.
- Explain PKCE unprompted for a mobile or single-page app: public clients can't hold a secret, the code can be intercepted, and the verifier/challenge pair binds the code to the app that started the flow. Mention that the implicit flow is deprecated.
- Draw the OAuth-versus-OIDC line clearly: access token is authorization (what you may do), ID token is authentication (who you are). State the trap — never authenticate a user from a raw access token — and name the token substitution attack it prevents.
- Mention scopes as least privilege, the state parameter as CSRF defence on the callback, and that every token is a bearer token that demands TLS. Those three signal you've shipped an integration, not just read about one.
References
- RFC 6749 — The OAuth 2.0 Authorization Framework — The core spec: the roles, the grant types, and the authorization code flow.
- RFC 7636 — Proof Key for Code Exchange (PKCE) — The code_verifier / code_challenge mechanism and the S256 (SHA-256) method.
- OpenID Connect Core 1.0 — The identity layer on top of OAuth: the ID token, its claims (sub, aud, iat), and the flows.
- RFC 6750 — OAuth 2.0 Bearer Token Usage — How bearer tokens are sent, and why 'whoever holds it is authorized' demands TLS.
- RFC 9700 — Best Current Practice for OAuth 2.0 Security — Why the implicit flow is deprecated and PKCE is recommended for every client.
- oauth.net — OAuth 2.0 — The community hub: specs, flows, and the current best-practice guidance in one place.