How OAuth lets an app act for you without your password.
A protocol designed in 2007 to solve exactly one problem: let an app act on your behalf without giving it your password. Two channels, three tokens, four hops. We'll walk every one.
What is OAuth 2? A valet key, not the master
A valet key, not the master.
OAuth 2.0 (RFC 6749, 2012) is an authorisation framework: it lets a user grant a third-party application limited access to their resources without sharing credentials. The key flows are Authorization Code (with PKCE for public clients), Client Credentials (for service-to-service), and Refresh Token. OAuth is authorisation, not authentication. For identity, layer OpenID Connect on top.
Before OAuth existed, if Yelp wanted to find your friends on Google Contacts, it literally asked you to type your raw Google password into the Yelp website. Yelp would then log in as you, scrape your contacts, and presumably store the password somewhere. This was catastrophic and ubiquitous.
OAuth 2.0 was engineered to solve one specific problem: delegated authorization. It gives a third-party app a "valet key" that drives the car without unlocking the trunk or stealing it. Three guarantees fall out of the design.
No password sharing
The user authenticates only with the auth server. The third-party app never sees the password. Revoke the app, the password is unaffected.
Scoped access
The token grants only the permissions the user explicitly approved on the consent screen. Read contacts, not delete them; see email, not change the password.
Revocable
Tokens expire. Refresh tokens can be invalidated. Whole grants can be revoked from a single screen. Without OAuth, "log out of all sessions" doesn't exist.
OAuth front channel vs back channel
Why the token never travels through the browser.
The genius of the Authorization Code flow is its strict separation of channels. The front channel is the user's browser. A hostile environment. URLs leak into history, referrers, server logs; XSS can read them; extensions can intercept them. So OAuth never sends the access token through the front channel.
Instead the auth server hands the browser a useless intermediate string. The authorization code. The browser carries this back to the application, which then opens a direct, server-to-server back channel over TLS to the auth server, presents the code, and only there receives the actual token. The browser never sees the token. XSS on the front channel cannot steal it.
Browser-mediated.
The user's browser sees this. URL parameters, redirects, fragment identifiers. Capable of leaking via referrers, logs, history. Carries: client_id, scopes, redirect_uri, state, code_challenge, and. Finally. The auth code.
Server-to-server.
Direct TLS from the application's backend to the auth server. The browser cannot read or interfere. Carries: code, code_verifier, client credentials, and. Finally. The access and refresh tokens.
The Authorization Code flow with PKCE
Step through every hop, from PKCE setup to token refresh.
The simulator below plays the canonical flow: a public client (mobile app or SPA) obtaining read access to a user's contacts. Four lanes — Browser, App, Auth Server, Resource API, and ten steps from PKCE setup through token refresh.
Before anything leaves the device, the client generates a fresh, single-use random string — the code_verifier — and its SHA-256 hash, the code_challenge. The verifier never leaves the client until step 5; the challenge will ride the front channel.
PKCE: Proof Key for Code Exchange
How public clients prove themselves without a stored secret.
The classic flow worked because confidential clients had a static client_secret that proved their identity at the token endpoint. But mobile apps, single-page apps, and CLIs are public clients — anyone who downloads the binary can decompile and extract the secret. There is no place to hide one.
PKCE replaces the static secret with a per-attempt cryptographic puzzle. Before redirecting, the client invents a random code_verifier, hashes it with SHA-256, and sends only the hash (the code_challenge) on the front channel. The auth server stores the hash against the issued code. When the client comes back to redeem the code, it sends the original verifier. The server hashes it. If hashes match, the request is authentic.
PKCE used to be a public-client recommendation. OAuth 2.1 makes it mandatory for every client. Confidential ones too. Because the cost is zero (one extra field) and the benefit is large (defends against authorization-code interception even with a stolen client secret).
The three OAuth tokens: access, refresh, and ID
Three tokens, three lifetimes.
A successful token endpoint response can return up to three distinct credentials. Each has a different lifetime, audience, and threat model.
- access
Access token · the bearer
The credential the client presents at the resource server. Bearer means possession is sufficient — no signature required from the holder. Short-lived (15–60 minutes) precisely because it is hard to revoke once issued. May be opaque (server introspects) or a JWT (server verifies the signature and claims locally).
- refresh
Refresh token · the renewer
Long-lived, used only against the auth server, never against an API. When the access token expires, the client trades the refresh for a new pair. Modern auth servers rotate refresh tokens — old ones become invalid. And implement reuse detection: if a rotated token is ever presented again, the entire chain is revoked because somebody stole something.
- id
ID token · the identity
Issued only when the openid scope is requested. A signed JWT carrying the user's identity claims (sub, email, name) plus issuer, audience, expiry, and nonce. Never presented to an API as authorization. The audience is always the client. This is the OIDC layer.
OAuth scopes: consent at the boundary
Each scope is one permission the user can approve or deny.
A scope is a string. It names a permission the client is asking for. The auth server renders these on the consent screen; the user picks Allow or Cancel; whatever was approved is bound to the issued token. The resource server checks scopes per-endpoint and rejects with 403 insufficient_scope when the token doesn't carry what the route requires.
Scope design is where most OAuth integrations get sloppy. read and write is often too coarse. It bundles powers a thoughtful user would have separated. read:contacts + read:profile + send:mail tells the user exactly what they're authorising. Generic admin scopes invite users to click through without reading.
All-or-nothing.
A single full_access scope. Users either grant everything or use nothing. The consent screen becomes a meaningless click-through. When the token leaks, blast radius is the entire account.
Verb-noun, narrow.
read:contacts, write:calendar, delete:repos. Each one a separate consent decision; each one a separate revocation target. Stolen tokens reveal a small surface.
OAuth is not authentication. Use OIDC
A token proves access, not identity.
This sentence sits at the core of more security incidents than any other in OAuth's history. OAuth is an authorization protocol. It issues tokens that say "the holder may read contacts." It does not say who the holder is.
If you treat the act of receiving an access token as proof of identity. "they got a Google token, so they must be a real Google user" — you have built an open redirect into your login. An attacker who has any valid Google token can trade it for an account on your site. The fix is OpenID Connect: layer on top of OAuth that explicitly asks the auth server to issue an id_token, a signed JWT whose audience is your client and whose claims you must verify before believing.
See the OIDC guide for the full identity layer.
You will be tricked. Every modern guide says the same thing — request the openid scope, get back an id_token, and verify signature, issuer, audience, expiry, and nonce before you decide who anyone is.
Where OAuth earns its scars in production
The mistakes teams make again and again.
Twenty years of production OAuth has produced a short, depressingly consistent list of ways teams get it wrong.
Don't use the implicit flow
Implicit returned the access token directly in the URL fragment. No back channel, no code exchange. Tokens leaked through history, referrers, screen-shares. OAuth 2.1 retires implicit. Use Authorization Code + PKCE for everything, including SPAs.
Don't store tokens in localStorage
localStorage is readable by any script that runs on the page. A single XSS payload steals every access token in there. Backend-for-Frontend pattern: store tokens in an httpOnly cookie on the BFF; the SPA never holds the token at all.
Always check state and nonce
state defends against CSRF on the redirect. nonce defends against ID-token replay. Both are mandatory. Both are routinely skipped by libraries that "just work" without them. Verify, don't trust.
Checking a token:
introspection and revocation.
Access tokens come in two shapes. JWT access tokens are self-describing. Every claim is signed into the token itself, and resource servers verify the signature offline. Cheap; but revocation is hard, because nobody other than the auth server knows the token has been recalled. Opaque access tokens are a random string; resource servers must call the auth server to ask what the token means. Authoritative; but adds a hop on every request.
# RFC 7662 — token introspection
POST /oauth/introspect HTTP/1.1
Host: auth.example.com
Authorization: Basic {client_id:client_secret}
Content-Type: application/x-www-form-urlencoded
token=2YotnFZFEjr1zCsicMWpAA&token_type_hint=access_token
# 200 OK
{
"active": true,
"scope": "read:orders write:orders",
"client_id": "spa-frontend",
"username": "ada@example.com",
"exp": 1717180800,
"iat": 1717177200,
"sub": "user_42",
"aud": "https://api.example.com",
"iss": "https://auth.example.com"
}
# RFC 7009 — token revocation
POST /oauth/revoke HTTP/1.1
Host: auth.example.com
Authorization: Basic {client_id:client_secret}
Content-Type: application/x-www-form-urlencoded
token=tGzv3JOkF0XG5Qx2TlKWIA&token_type_hint=refresh_token
# 200 OK (always, by spec — even for already-revoked tokens) Is this token still good?
Resource servers POST the token to /introspect and get back the canonical claims plus an active boolean. The auth server can take revocation, expiry, and account-lock state into account in real time. The trade-off is one extra round-trip per API call — usually mitigated by caching the result for a few seconds.
Hang up the token.
The client (or the user via account settings) POSTs the token to /revoke. The auth server marks it inactive. Introspection now returns "active": false, and any sibling tokens issued from the same refresh chain typically die too. Revocation always returns 200 to avoid leaking whether a token existed.
If a JWT can't be revoked individually, the workaround is to keep it short. Five to fifteen minutes, and lean on refresh-token revocation (Part 10) for "log out everywhere". A small denylist of recently-revoked JWT IDs (jti) bridges the gap. Most production systems combine the two: JWT for hot-path verification, opaque tokens for sensitive operations, introspection on a sample.
Refresh tokens:
rotation and reuse detection.
Access tokens expire fast (5 – 15 minutes) so a leaked one has a small blast radius. Refresh tokens live longer (days, weeks) and let the client mint new access tokens without bothering the user. They're the dangerous artifact in OAuth: long-lived, single-purpose, often stored in a browser. Modern best practice mitigates the risk in three layers.
- 01
Rotation. Every refresh issues a new refresh token
When a client calls /token with grant_type=refresh_token, the auth server issues a new refresh token alongside the new access token, and invalidates the old refresh token. The window in which a stolen refresh token is useful shrinks to one round-trip.
- 02
Reuse detection. Kill the family on collision
If an old (already-rotated) refresh token is ever presented again, the auth server treats it as a theft signal. The entire refresh chain. Every descendant token. Is revoked. The legitimate client and the attacker both get logged out; the user has to re-authenticate. Painful, but the only sound response when rotation has been broken.
- 03
Sender-constrained. Bind the token to the client
mTLS (RFC 8705) ties the token to the TLS client certificate that requested it; DPoP (RFC 9449) ties it to a public key in a JWS that the client signs per request. In both cases, a token stolen off-device cannot be used because the attacker doesn't have the matching key. Becoming standard for high-value APIs (open banking, OPA, cloud control planes).
- 04
Storage. Never localStorage
In a browser, refresh tokens belong in an httpOnly; Secure; SameSite=Lax cookie scoped to the auth domain — JS can't read them, XSS can't steal them, and CSRF is blocked by SameSite. Native apps store them in the OS keychain (iOS Keychain, Android Keystore) gated behind biometric unlock for sensitive scopes.
The client generates an asymmetric keypair (typically EC P-256), keeps the private key in IndexedDB protected by the WebCrypto subtle key store, and includes the public key's thumbprint when requesting tokens. On every API call it signs a DPoP proof JWT (binds method + URL + nonce + timestamp) with that private key and sends it in the DPoP header. The resource server verifies the JWT signature against the public key embedded in the access token. Theft of just the token is now useless without the matching private key. Which never leaves the device.
What OAuth
defends against.
Almost every clause in the OAuth 2.1 BCP exists because someone broke an earlier version. Reading the protocol with the threat model in mind makes the inelegant parts make sense.
| Attack | What it does | Mitigation |
|---|---|---|
| Authorization-code interception | Attacker steals the code from the redirect (logs, history, malicious URI handler). | PKCE (Part 04). Code without verifier is useless. |
| CSRF on redirect | Attacker tricks the user's browser into delivering an attacker-issued code to the victim's session. | state parameter, bound to the user's session. |
| Open redirector / mix-up | Attacker redirects a victim through a flexible redirect_uri to leak the code. | Strict exact-match registration of redirect_uri. |
| Token replay | Attacker reuses a stolen access or refresh token from another device. | DPoP or mTLS sender constraint (Part 10). |
| Refresh-token theft | XSS lifts the long-lived refresh token from a browser. | httpOnly cookie storage; rotation + reuse detection (Part 10). |
| Implicit-flow leakage | Tokens delivered in the URL fragment leak through history, referrers, browser extensions. | Implicit flow is removed in OAuth 2.1. Use code+PKCE. |
| Phishing for credentials | Fake auth pages collect username + password. | FIDO2 / WebAuthn at the auth server. Out of OAuth's scope, but where the war moves. |
| Excessive scope grant | User clicks "approve" on a permissive list; client now has more than it needs. | Incremental authorization; step-up auth for elevated scopes; per-tenant approval review. |
Authorization Code flow + PKCE for every client (public or confidential), exact-match redirect_uri, mandatory state, refresh-token rotation with reuse detection, refresh tokens in httpOnly cookies (browser) or OS keychain (native), DPoP on access tokens for high-value APIs, and OIDC ID tokens for identity. Implicit and password grants are simply gone.
OAuth 2.1, DPoP, and the modern security additions
What's new since 2012.
OAuth 2.1 (draft, IETF) consolidates 12+ years of best-practice extensions into a single spec. Key additions: PKCE mandatory for all flows, deprecation of the implicit flow (which leaked tokens through URL fragments), deprecation of resource-owner password credentials, redirect URI exact-match required. Most modern providers already enforce 2.1 behaviour even without the spec finalising.
DPoP (Demonstration of Proof-of-Possession, RFC 9449, 2023) binds an access token to a client-held key pair. Even if the token leaks (e.g. via an XSS), the attacker can't use it without the private key. The price: every API request includes a DPoP JWT signed with the client key. Browsers can implement this with non-extractable WebCrypto keys; mobile apps with the platform keystore.
PAR (Pushed Authorization Requests, RFC 9126, 2021) moves the authorization request from a URL parameter to a server-to-server POST, eliminating the URL-tampering attack class. Mandatory in newer OpenID Connect-FAPI profiles (financial-grade APIs).
The token-binding alternative. Token Binding (RFC 8471) was an earlier attempt to bind tokens to TLS sessions. Implemented by Microsoft, then deprecated when Chrome dropped support in 2018. DPoP is the surviving alternative.
What this means for new apps. Use OAuth 2.1 idioms (PKCE always, no implicit flow, no password grant). Layer DPoP if you handle high-value tokens (financial, healthcare, admin). Trust your IdP's defaults; Auth0, Okta, Google, and Microsoft all enforce modern best practices in their default configurations.
OAuth was never beautiful. It is a treaty negotiated between security people, identity vendors, browsers, and developer experience, and treaties have seams. But after fifteen years of patching (PKCE, refresh rotation, mTLS, DPoP, OAuth 2.1, BFF), the contract works. The user authenticates once with the auth server. The client never holds the password. The token is scoped, expirable, and revocable. Every time you click "Sign in with Google" and your password stays on Google's domain, that's the contract working as designed.
Read
further.
- IETF · OAuth 2.1 draftThe Modern OAuth 2.1 SpecificationConsolidates fifteen years of patches: deprecates implicit and ROPC, mandates PKCE, formalizes refresh-token rotation. The current target spec.
- IETF · RFC 7636PKCE. Proof Key for Code ExchangeThe canonical spec for what Part 04 unpacks. Short and tight; you can finish it in twenty minutes.
- IETF · RFC 9700OAuth 2.0 Security Best Current PracticeForty pages of "this is what attackers do, this is what you must do." Required reading before shipping any production OAuth client.
- Semicolony guideOpenID ConnectThe identity layer on top of OAuth. ID tokens, claims, discovery, and why authorization is not authentication.
- Semicolony guideHTTPSThe TLS that makes the entire back channel possible. Read it if Part 02's "server-to-server TLS" still feels handwavy.
- Semicolony toolJWT EncoderInspect, decode, sign, and verify JWTs locally. Pairs with Part 05 for examining real ID tokens and access tokens.
- IETF · RFC 7662OAuth 2.0 Token IntrospectionThe protocol behind Part 09's /introspect endpoint. Two pages of HTTP, then everything else is examples.
- IETF · RFC 7009OAuth 2.0 Token RevocationThe companion to introspection. Defines exactly what "revoked" means and why the response is always 200.
- IETF · RFC 9449DPoP. Demonstrating Proof of PossessionThe sender-constraint mechanism in Part 10. The cleanest way to bind a token to a device without TLS-level certs.
- IETF · RFC 8705OAuth 2.0 Mutual-TLS Client AuthenticationDPoP's heavier sibling. The standard for environments that already have a client-cert PKI (banking, gov, mTLS service meshes).
- oauth.netOAuth 2.1. What changedA short, opinionated summary of the deprecations Part 11 references. Removed: implicit, password grant. Required: PKCE for everyone.
- Scott BradyThe BFF (Backend-for-Frontend) pattern, illustratedAn end-to-end deployment for SPAs that keeps refresh tokens out of the browser entirely. A popular Part 10 conclusion.
- Aaron PareckiOAuth 2.0 SimplifiedAn accessible, cross-flow walkthrough by one of the working group editors. Best read after the RFCs, not before.