Security · Lesson 06
OAuth 2.0
OAuth 2.0 is a framework for delegated authorization — it lets a user hand a limited key to a third-party app without ever revealing their password. It is not authentication; it is a permission slip.
By the end you'll be able to
- Name the four OAuth 2.0 roles and explain what each one does.
- Trace the authorization-code flow with PKCE step by step.
- Choose between authorization-code and client-credentials flows for a given scenario.
The valet key analogy
Some luxury cars ship with a "valet key" — it starts the engine and unlocks the door, but it will not open the glove compartment or the boot. You hand it to the parking attendant without worrying they will rifle through your belongings. You have delegated limited access, not full access.
OAuth 2.0 is the valet key for software. A user can grant a third-party app permission to read their calendar without sharing their Google password. The scope of access is bounded; the original credential is never exposed.
The four roles
| Role | What it is | Example |
|---|---|---|
| Resource Owner | The user who owns the data and can grant access to it. | You — the Gmail account holder. |
| Client | The third-party application requesting access on behalf of the resource owner. | A scheduling app wanting to read your calendar. |
| Authorization Server | Issues access tokens after the resource owner grants permission. | Google's OAuth endpoint at accounts.google.com. |
| Resource Server | Hosts the protected resources; validates access tokens before serving data. | Google Calendar API. |
Authorization-code flow with PKCE
The most secure and most common flow for apps with a user present. PKCE (Proof Key for Code Exchange, pronounced "pixie") plugs a code-interception attack by binding the authorization request to the token exchange with a secret verifier the client generates itself.
Client-credentials flow
When there is no human in the loop — a back-end service calling another back-end service — the authorization-code flow is meaningless: there is no user to show a consent screen to. Use client-credentials flow instead. The client authenticates directly with the authorization server using its own credentials (a client ID and secret) and receives an access token.
# Client-credentials token request (machine-to-machine)
POST /oauth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=svc_billing
&client_secret=••••••••
&scope=invoices:read invoices:write
# Response
HTTP/1.1 200 OK
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "invoices:read invoices:write"
}
Access tokens, refresh tokens, and scopes
Access tokens are short-lived credentials (typically 15–60 minutes) that the client presents to the resource server. They are intentionally short so that a stolen token expires quickly. Refresh tokens are longer-lived and used only at the authorization server to obtain a new access token without re-authenticating the user. Scopes define exactly which operations the token permits — calendar:read does not grant calendar:write. Requesting the minimum set of scopes is both good security practice and a good user-experience choice (consent screens are less alarming).
"OAuth is authorization, not authentication" is a common interviewer test. OAuth 2.0 tells you what a client may do with a token — it says nothing about who the user is. The token might be for a service account with no human attached. If an interviewer asks "how would you add login with Google?", the answer is not bare OAuth — it is OpenID Connect built on top of OAuth (next lesson). Knowing this distinction separates candidates who have read a tutorial from those who understand the system.
Two pitfalls: (1) The implicit flow is deprecated. It returned the access token directly in the URL fragment — visible in browser history, logs, and referrer headers. Use authorization-code + PKCE instead, even for single-page apps. (2) Storing tokens in localStorage. Any JavaScript on the page (including third-party analytics) can read localStorage. Prefer short-lived access tokens in memory and longer-lived refresh tokens in HttpOnly cookies, which JavaScript cannot touch.
Do request only the scopes your app actually needs and display them plainly on the consent screen. Don't request broad scopes "just in case" — users will either deny the permission or, worse, click through without reading, and a future breach will expose far more data than necessary. Least-privilege applies to delegated access too.
Under the hood: authorization-code + PKCE message by message
The flow involves seven distinct HTTP messages. Each parameter has a security purpose — knowing what each one prevents tells you immediately which attack surfaces the flow is closing.
Here is the full first redirect in raw form, with each parameter annotated:
# ① The redirect URL the client constructs (line-wrapped for readability)
GET https://auth.example.com/authorize
?response_type=code
&client_id=my_app_xyz
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=calendar%3Aread%20profile
&state=t9kRmQ2pX8vL // random, stored in session — CSRF protection
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256 // SHA256(code_verifier), base64url-encoded
# Client generated locally (before the redirect):
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" // random 43–128 chars
code_challenge = base64url(SHA256(code_verifier)) // sent in ①; verifier kept secret
# ④ Token exchange — client proves it started the flow
POST https://auth.example.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA // from step ③ redirect; single-use, short-lived
&redirect_uri=https://app.example.com/callback // must match ①
&client_id=my_app_xyz
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk // auth server computes SHA256 and matches code_challenge
# ⑤ Successful response
HTTP/1.1 200 OK
{
"access_token": "eyJhbGci...",
"refresh_token": "8xLOxBtZp8...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "calendar:read profile"
}
What each parameter prevents
| Parameter | Location | Attack it prevents |
|---|---|---|
state | Step ①③ | CSRF — an attacker initiates a flow and tricks the victim's browser into completing it. The client generates state randomly, stores it in the session, and rejects any callback where state doesn't match. |
code_challenge + code_verifier | Steps ①④ | Authorization-code interception — if a malicious app on the same device intercepts the redirect, it has the code but not the code_verifier the client kept locally, so the token exchange fails with invalid_grant. |
redirect_uri (exact match) | Steps ①④ | Open redirect / token leakage — the auth server must reject any redirect_uri not pre-registered. Mismatch causes an invalid_redirect_uri error before the code is issued. |
scope | Step ① | Over-privilege — limits what the access token can do; resource server rejects tokens with wrong/missing scopes. |
Single-use code | Steps ③④ | Code replay — the auth server invalidates the code immediately on exchange; a second use returns invalid_grant. |
How to debug & inspect it
OAuth flows fail in exactly one of three places: the authorize redirect, the callback (with an error in the query string), or the token POST. The browser's Network panel and a curl reproduction find each one quickly.
| Error / symptom | Stage | Cause | Fix |
|---|---|---|---|
invalid_request: redirect_uri mismatch | ① /authorize | redirect_uri in the request doesn't exactly match a pre-registered URI (even a trailing slash differs) | Register the exact URI in the auth server's client config; URL-encode consistently |
invalid_request: missing code_challenge | ① /authorize | PKCE required by the auth server but not sent | Generate a code_verifier, hash it with S256, and add both params |
Callback arrives with error=access_denied | ② Consent | User clicked "Deny" on the consent screen, or the scope requested exceeds what the resource owner allows | Request fewer/different scopes; re-display a useful error to the user |
state mismatch in your client | ③ Callback | Session was lost between redirect and callback, or CSRF attack in progress | Verify session cookie is present; increase session TTL; confirm SameSite=Lax/Strict on cookie |
invalid_grant on POST /token | ④ Token exchange | Code already used, code expired (>60 s typical), redirect_uri doesn't match ①, or code_verifier doesn't match code_challenge | Restart the flow (get a fresh code); never log or cache authorization codes |
invalid_client | ④ Token exchange | Wrong client_id, or client secret incorrect/missing for confidential clients | Check environment variables; rotate the secret and update both places |
Access token rejected by resource server with 401 | ⑥ API call | Token expired, wrong audience (aud), or resource server has wrong signing key | Decode token (base64) to inspect exp and aud; confirm the resource server's JWKS URL |
OAuth debug checklist:
- Open Network tab, initiate the flow, and inspect the initial 302 Location header — are all required params present and correctly URL-encoded?
- Look at the callback URL after the auth server redirects back — does it have
code=andstate=? Or does it haveerror=? Readerror_description. - Verify your client's stored
statematches what the callback returned. - Reproduce the POST /token in curl (with a fresh code). Read the error body — OAuth servers return structured JSON error objects.
- Decode the returned access token (split on
., base64-decode the middle segment) to inspectexp,aud, andscope.
In production: how leading APIs authenticate
OAuth 2.0 is a framework, not a single product. Different platforms implement it with different trade-offs — some add a proprietary token type, others layer OpenID Connect on top for identity, and at least one (HubSpot) has publicly deprecated bare API keys in favour of OAuth flows and scoped private-app tokens.
| API / Provider | OAuth mechanism | Notable detail |
|---|---|---|
| HubSpot | OAuth 2.0 (authorization-code flow) for third-party apps; private-app access tokens for internal integrations. Legacy API keys were deprecated in November 2022 because they granted unscoped, account-wide access with no way to limit what a compromised key could reach. | Private-app tokens behave like scoped OAuth tokens without requiring a redirect flow — useful for server-side scripts that do not have a user to redirect. See HubSpot OAuth & private apps docs. |
| OAuth 2.0 for authorization (access token) plus OpenID Connect for authentication (ID token, a JWT containing identity claims). The two tokens serve distinct purposes: the access token unlocks API resources; the ID token proves who the user is. | Google's auth server issues both tokens in a single token endpoint response when the openid scope is requested. See Google Identity docs. |
|
| GitHub OAuth Apps vs GitHub Apps | OAuth Apps follow the standard authorization-code flow and receive a token tied to the user — the token acts on behalf of the authorizing user, inheriting their repository permissions. GitHub Apps use a different mechanism: they sign a JWT with the app's private key and exchange it for a scoped installation access token that acts on behalf of the installation, not an individual user. | GitHub recommends Apps over OAuth Apps for new integrations because installation tokens expire (≤ 1 hour) and carry only the scopes declared in the App manifest. See GitHub Apps vs OAuth Apps docs. |
Deep dive: the GitHub App JWT-to-installation-token exchange
GitHub Apps do not run through a standard user-facing OAuth consent screen. Instead, the flow has two phases. In the first phase, the App authenticates as itself: it signs a short-lived JWT (10-minute maximum) using an RSA private key registered with GitHub at app creation time. This JWT carries the App's ID in the iss claim. In the second phase, the App sends that JWT to GitHub's REST API to request an installation access token scoped to a specific repository installation. GitHub verifies the JWT signature against the registered public key, checks that the requested permissions are within the App's declared manifest, and returns a bearer token valid for up to one hour.
This pattern is OAuth-adjacent but not a textbook OAuth flow: there is no user redirect, no authorization code, and no refresh token. The security properties are similar — the signing key never travels over the wire, the issued token is short-lived, and the scope is declared in advance rather than requested at runtime. It solves the same problem as client-credentials flow (no user present) but with stronger identity assurance because the private key is what authenticates the App, not a shared secret.
The practical lesson for API designers: short-lived, scoped tokens are safer than long-lived keys regardless of the mechanism that issues them. HubSpot's 2022 API-key deprecation reflects the same conclusion — an unscoped key that never expires is a liability.
🧠 Quick check
1. Which OAuth 2.0 role issues access tokens?
The Authorization Server is responsible for authenticating the resource owner, obtaining consent, and issuing tokens. The Resource Server only validates tokens it receives; it does not create them.
2. PKCE in the authorization-code flow protects against:
Without PKCE, an intercepted authorization code can be exchanged for a token by anyone who has it. PKCE requires the exchanger to prove they generated the original request by supplying the code verifier whose hash was sent in step 1 — something only the legitimate client knows.
3. When should you use client-credentials flow instead of authorization-code flow?
Client-credentials flow is for machine-to-machine (M2M) scenarios where there is no resource owner to grant consent. Authorization-code flow is for delegating user data to a client application.
4. Why are access tokens kept short-lived while refresh tokens are longer-lived?
Access tokens travel frequently to the resource server — more exposure surface. Keeping them short-lived limits the damage window. Refresh tokens go only to the trusted authorization server and are used infrequently, reducing their interception risk.
✍️ Exercise: design the OAuth flow for a travel app
A travel app "JetSet" wants to let users book flights using points from their airline loyalty account. The airline's API requires OAuth 2.0. Describe which flow JetSet should use and why, name each of the four roles in this context, and list the minimum scopes you would request.
Model answer:
- Flow: authorization-code with PKCE. A human user (the loyalty account holder) must consent to JetSet accessing their points balance and redemption capability. There is a user present, so authorization-code is correct. PKCE is required for public clients (mobile/SPA).
- Roles: Resource Owner = the traveller; Client = JetSet app; Authorization Server = the airline's OAuth endpoint; Resource Server = the airline's loyalty API.
- Minimum scopes:
points:read(to show balance),points:redeem(to book). Do not requestaccount:writeorprofile:edit— unnecessary scope that users would rightly refuse and that creates liability in a breach.
Rubric: Full marks for correct flow choice with PKCE justification, correct four-role mapping, and meaningful least-privilege scope selection. Partial marks if flow is correct but PKCE is not mentioned, or if scopes are too broad. Bonus: describing token storage strategy (access token in memory, refresh token in HttpOnly cookie).
Key takeaways
- OAuth 2.0 is delegated authorization — not authentication. It tells you what a token may do, not who the user is.
- The four roles: Resource Owner, Client, Authorization Server, Resource Server.
- Use authorization-code + PKCE when a user is present; use client-credentials for machine-to-machine flows.
- Access tokens are short-lived; refresh tokens obtain new access tokens without re-authentication.
- Scopes enforce least privilege. Request only what you need; never use the implicit flow.