API Design

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.

⏱ 13 min Difficulty: core Prereq: AuthN vs AuthZ (Lesson 05)

By the end you'll be able to

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

RoleWhat it isExample
Resource OwnerThe user who owns the data and can grant access to it.You — the Gmail account holder.
ClientThe third-party application requesting access on behalf of the resource owner.A scheduling app wanting to read your calendar.
Authorization ServerIssues access tokens after the resource owner grants permission.Google's OAuth endpoint at accounts.google.com.
Resource ServerHosts 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.

Browser/App Auth Server Resource Server 1. redirect with code_challenge (PKCE) 2. show login & consent screen 3. user grants permission 4. redirect back with authorization code 5. POST code + code_verifier (PKCE check) 6. access_token + refresh_token 7. API request with Bearer access_token 8. 200 OK — protected resource (When access_token expires, use refresh_token at Auth Server to get a new one — step 5 again, no user interaction needed)
Authorization-code flow with PKCE. The authorization code is short-lived and single-use; the PKCE verifier ensures only the originating client can exchange it for a token.

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).

🎯 Interview angle

"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.

⚠️ Common trap

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 this, not that

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.

Client App Auth Server Resource Server ① GET /authorize client_id, redirect_uri, scope, response_type=code state=<random> code_challenge=SHA256(verifier) code_challenge_method=S256 ② show login & consent UI user consents ③ 302 → redirect_uri?code=AUTH_CODE&state=<same random> state echoed back → client verifies it matches what it sent (CSRF defence) ④ POST /token grant_type=authorization_code code=AUTH_CODE redirect_uri=<same> client_id code_verifier=<original secret> server computes SHA256(code_verifier) and matches code_challenge if mismatch → 400 invalid_grant (code_interception defence) ⑤ 200 { access_token, refresh_token, expires_in, scope } ⑥ GET /calendar/events Authorization: Bearer <access_token> ⑦ 200 OK — calendar events When access_token expires: POST /token grant_type=refresh_token refresh_token=<token> → new access_token (no user interaction required)
Each parameter in the authorize redirect and token POST closes a specific attack. The sequence is deterministic — any deviation produces an error the client must handle.

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

ParameterLocationAttack it prevents
stateStep ①③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_verifierSteps ①④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.
scopeStep ①Over-privilege — limits what the access token can do; resource server rejects tokens with wrong/missing scopes.
Single-use codeSteps ③④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.

# Inspect the /authorize redirect in DevTools Network tab # Find the 302 redirect from your app — inspect the Location header: Location: https://auth.example.com/authorize?response_type=code&client_id=...&state=...&code_challenge=... # Verify: client_id, redirect_uri (URL-decoded), scope, state present, code_challenge present # If the auth server redirects back with an error in the callback: https://app.example.com/callback?error=invalid_request&error_description=redirect_uri+mismatch # Read error_description — it is machine-readable and precise # Reproduce the token exchange with curl to isolate server-side errors: curl -X POST https://auth.example.com/token \ -d "grant_type=authorization_code" \ -d "code=SplxlOBeZQQYbYS6WxSbIA" \ -d "redirect_uri=https://app.example.com/callback" \ -d "client_id=my_app_xyz" \ -d "code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" {"error":"invalid_grant","error_description":"code already used"} # "already used" = you reproduced the POST a second time; get a fresh code
Error / symptomStageCauseFix
invalid_request: redirect_uri mismatch① /authorizeredirect_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① /authorizePKCE required by the auth server but not sentGenerate a code_verifier, hash it with S256, and add both params
Callback arrives with error=access_denied② ConsentUser clicked "Deny" on the consent screen, or the scope requested exceeds what the resource owner allowsRequest fewer/different scopes; re-display a useful error to the user
state mismatch in your client③ CallbackSession was lost between redirect and callback, or CSRF attack in progressVerify session cookie is present; increase session TTL; confirm SameSite=Lax/Strict on cookie
invalid_grant on POST /token④ Token exchangeCode already used, code expired (>60 s typical), redirect_uri doesn't match ①, or code_verifier doesn't match code_challengeRestart the flow (get a fresh code); never log or cache authorization codes
invalid_client④ Token exchangeWrong client_id, or client secret incorrect/missing for confidential clientsCheck environment variables; rotate the secret and update both places
Access token rejected by resource server with 401⑥ API callToken expired, wrong audience (aud), or resource server has wrong signing keyDecode token (base64) to inspect exp and aud; confirm the resource server's JWKS URL

OAuth debug checklist:

  1. Open Network tab, initiate the flow, and inspect the initial 302 Location header — are all required params present and correctly URL-encoded?
  2. Look at the callback URL after the auth server redirects back — does it have code= and state=? Or does it have error=? Read error_description.
  3. Verify your client's stored state matches what the callback returned.
  4. Reproduce the POST /token in curl (with a fresh code). Read the error body — OAuth servers return structured JSON error objects.
  5. Decode the returned access token (split on ., base64-decode the middle segment) to inspect exp, aud, and scope.

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 / ProviderOAuth mechanismNotable 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.
Google 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.

How leading APIs do it

🧠 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:

  1. 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).
  2. Roles: Resource Owner = the traveller; Client = JetSet app; Authorization Server = the airline's OAuth endpoint; Resource Server = the airline's loyalty API.
  3. Minimum scopes: points:read (to show balance), points:redeem (to book). Do not request account:write or profile: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

Sources & further reading