OAuth 2 — The Authorization Framework
Authorization Code + PKCE, refresh tokens, scopes, the four roles. The protocol every modern API uses and gets wrong half the time.
What it is#
OAuth 2 is an authorization framework that lets a user grant a third-party application limited access to their resources on a service, without sharing the user’s password. It is defined by RFC 6749 (the framework) and a cluster of companion RFCs (PKCE in 7636, token introspection in 7662, JWT-shaped access tokens in 9068).
The framework is built around four roles:
- Resource Owner — the user.
- Resource Server — the API that holds the user’s data (Google Drive, GitHub, Spotify).
- Client — the third-party app that wants access (a calendar tool wanting your Google Calendar, a CI tool wanting your GitHub repos).
- Authorization Server — the service that issues access tokens (often the same company as the resource server; can be split).
OAuth 2 is not an authentication protocol. It tells you what the bearer of a token is allowed to do; it does not tell you who the bearer is. OpenID Connect is the identity layer built on top of OAuth 2 — same flow, plus an id_token that carries the user’s identity. The two are routinely conflated; the senior signal in an interview is to distinguish them cleanly.
When to use it#
Reach for OAuth 2 when:
- A third party needs scoped access to your users’ resources. You’re not Google — you’re a third-party tool that wants to read a user’s Google Calendar. OAuth 2 is how Google hands you a token without you ever seeing the user’s password.
- Your platform has third-party developers. Stripe Connect, GitHub Apps, Slack apps, Google Workspace add-ons, Zoom apps — all OAuth 2 flows.
- First-party clients separated by trust boundary. Even a first-party mobile app talking to a first-party backend benefits from OAuth 2 — tokens that scope, expire, and revoke are better than long-lived sessions.
Avoid OAuth 2 when:
- Server-to-server inside one trust boundary. mTLS or a static API key is simpler.
- The auth model is “users log in directly to my product”. That’s session-based auth (cookies + a session store) or OpenID Connect (OAuth + identity). Raw OAuth 2 is the wrong abstraction.
- You need single sign-on across many SaaS apps in an enterprise. SAML is the older, broader-deployed answer; OIDC is the modern one. Both layer identity onto an auth flow.
How it works#
OAuth 2 defines several grant types — different protocol flows for different client situations. Two matter today:
Authorization Code Grant + PKCE (the modern default)#
The flow for any client that runs in a browser or mobile app. PKCE (Proof Key for Code Exchange, RFC 7636) is mandatory — it’s what protects the flow against authorization-code interception.
User-Agent Client Authorization Resource (browser) App Server Server │ │ │ │ │ 1. clicks │ │ │ │ "Connect" │ │ │ │─────────────────►│ │ │ │ │ 2. computes random │ │ │ │ code_verifier; │ │ │ │ challenge = SHA256(verifier) │ │ │ │ │ │ 3. redirect to /authorize? │ │ │ response_type=code& │ │ │ client_id=...& │ │ │ redirect_uri=...& │ │ │ scope=calendar.read& │ │ │ code_challenge=<challenge>& │ │ │ code_challenge_method=S256 │ │ │◄─────────────────│ │ │ │ 4. user logs in, approves scopes │ │ │─────────────────────────────────────────►│ │ │ 5. redirect back with ?code=AUTH_CODE │ │ │◄─────────────────────────────────────────│ │ │─────────────────►│ │ │ │ │ 6. POST /token │ │ │ │ code=AUTH_CODE │ │ │ │ code_verifier=... │ │ │ │──────────────────────►│ │ │ │ 7. access_token │ │ │ │ + refresh_token │ │ │ │◄──────────────────────│ │ │ │ 8. GET /calendar │ │ │ │ Authorization: Bearer access_token │ │ │────────────────────────────────────────►│ │ │ 9. user's calendar data │ │ │◄────────────────────────────────────────│The PKCE addition (code_verifier + code_challenge) prevents an attacker who intercepts the authorization code from exchanging it — without the original verifier, the token endpoint refuses.
Client Credentials Grant (machine-to-machine)#
For server-to-server calls with no user in the loop. The client authenticates with client_id + client_secret and gets back an access token directly.
POST /oauth/token HTTP/1.1Host: auth.example.comContent-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=svc_invoicing&client_secret=<secret>&scope=invoice.read invoice.writeThe other historical grant types (Implicit, Resource Owner Password Credentials) are deprecated — Implicit is unsafe without PKCE, and Password Grant defeats the entire point of OAuth (the user gives their password to the client). Don’t use them.
Acquiring a token — three-language example#
The Authorization Code exchange (step 6 → 7 above) in Python, Go, and Node. Realistic shape.
import requestsimport hashlibimport secretsimport base64
# Generated earlier, before redirecting the usercode_verifier = secrets.token_urlsafe(64)
resp = requests.post( "https://auth.example.com/oauth/token", data={ "grant_type": "authorization_code", "code": AUTH_CODE, "redirect_uri": "https://app.example.com/callback", "client_id": "client_abc", "code_verifier": code_verifier, }, timeout=5,)resp.raise_for_status()tokens = resp.json()# tokens = {"access_token": "...", "refresh_token": "...",# "expires_in": 3600, "token_type": "Bearer", "scope": "calendar.read"}package main
import ( "encoding/json" "net/http" "net/url" "strings")
type TokenResponse struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` TokenType string `json:"token_type"` Scope string `json:"scope"`}
func exchange(code, verifier string) (*TokenResponse, error) { form := url.Values{} form.Set("grant_type", "authorization_code") form.Set("code", code) form.Set("redirect_uri", "https://app.example.com/callback") form.Set("client_id", "client_abc") form.Set("code_verifier", verifier)
resp, err := http.PostForm("https://auth.example.com/oauth/token", form) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != 200 { return nil, &http.ProtocolError{ErrorString: "token endpoint refused"} } var t TokenResponse err = json.NewDecoder(resp.Body).Decode(&t) return &t, err}
// codeChallenge = base64url(sha256(verifier))// Skipped here for brevity — same algorithm as the Python example._ = strings.Builder{}async function exchange(code, codeVerifier) { const body = new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: "https://app.example.com/callback", client_id: "client_abc", code_verifier: codeVerifier, });
const resp = await fetch("https://auth.example.com/oauth/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, }); if (!resp.ok) throw new Error(`token endpoint refused: ${resp.status}`); return resp.json(); // { access_token: "...", refresh_token: "...", // expires_in: 3600, token_type: "Bearer", scope: "calendar.read" }}Refresh tokens#
Access tokens are short-lived (commonly 1 hour). When they expire, the client uses the refresh token to mint a new one without bothering the user:
POST /oauth/token HTTP/1.1Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=<long-lived-token>&client_id=client_abcRefresh tokens should be rotated on use (the server returns a new refresh token and invalidates the old one) — this limits the window for a stolen refresh token to do damage.
Scopes#
Scopes are the OAuth 2 way to say “you can do X but not Y”. The client requests scopes at authorization time; the user approves them; the resource server enforces them per request. Naming convention is dotted (calendar.read, repo.write, admin:org); enforcement is per-endpoint.
A well-designed scope vocabulary is narrow (each scope grants one logical thing) and stable (renaming a scope breaks every existing token). Stripe Connect, Google Workspace, and GitHub Apps are studies in scope design.
Variants#
| Variant | Mechanism | When it fits |
|---|---|---|
| Authorization Code + PKCE | Three-leg flow with PKCE; refresh tokens. | Browser and mobile clients with a user. The modern default. |
| Client Credentials | Two-leg flow; client_id + secret → access_token. | Server-to-server with no user. |
| Device Authorization Grant | Display a code on a TV, user enters it on their phone. | TVs, CLI tools, IoT — devices without easy text input. |
| Token Exchange (RFC 8693) | Swap one token for another (e.g. delegation). | Microservice meshes; on-behalf-of patterns. |
Trade-offs#
What OAuth 2 gives you:
- Standardised delegation. Every modern API consumes it; libraries exist in every language.
- Revocable, scoped, time-limited access. A leaked access token expires in an hour; a stolen refresh token can be revoked.
- No passwords across boundaries. Third parties never see the user’s password.
- Composes with OIDC. Adding identity is one extra response field (
id_token).
What OAuth 2 costs you:
- Protocol complexity. Four roles, multiple grant types, deprecated flows, PKCE, refresh rotation, scope design — it’s a lot.
- Cookie-vs-token debates. First-party SPAs can use OAuth 2 or session cookies; the choice has security trade-offs neither side fully wins.
- Token storage on the client. Where do you put the access token? localStorage (XSS-exposed), an HttpOnly cookie (CSRF concerns), an in-memory store (lost on reload)?
- Discovery and metadata. Each provider publishes its own
.well-known/oauth-authorization-serverdocument; clients must read it.
Common pitfalls#
- Treating access tokens as identity. They’re not — they say what the bearer can do, not who they are. Use OIDC
id_tokenfor identity. - Skipping PKCE on browser-based clients. Implicit grant is deprecated for a reason; PKCE on Authorization Code is mandatory now.
- Long-lived access tokens. “Just give it 30 days” defeats the entire revocation model. Keep access tokens short; rely on refresh tokens for longevity.
- Wildcard
redirect_uri. Open redirect → account takeover. Always match the exact URL. - Storing tokens in
localStoragewithout an XSS strategy. Any XSS exfiltrates the token. The HttpOnly-cookie pattern is safer (and CSRF can be mitigated with SameSite). - No refresh-token rotation. A stolen refresh token is permanent if you don’t rotate on use.
- Confusing OAuth 2 with OpenID Connect in design documents. The auditor will pick on it.
Related building blocks#
- OpenID Connect and SAML — OIDC layers identity on OAuth; SAML is the older enterprise alternative.
- Authentication vs Authorization — the two-word vocabulary you must use precisely.
- Transport Layer Security (TLS) — OAuth assumes TLS everywhere; without it, every flow is unsafe.
- API Security — An Overview — where OAuth 2 sits in the broader API-security model.
- API Security — A High-Level Recap — the full checklist; OAuth 2 is one box on it.