Purpose · History · OAuth 1.0 → 2.0 → 2.1 · flows, tokens, scopes, PKCE, OIDC. Companion deck covers MCP & provider landscape.
OAuth solves one specific problem: letting an application act on a user's behalf at a third-party service, without the user handing over their password. It is an authorisation framework — not, by itself, an authentication protocol.
"Application A wants to access resource R belonging to user U, hosted at server S, without ever seeing U's password."
Every grant type, token format, and security mechanism in OAuth exists to safely accomplish that one sentence.
OAuth 1.0a was a single, self-contained protocol. Every request was signed with HMAC-SHA1 (or RSA-SHA1) using a per-client consumer secret plus a per-user token secret. No TLS required — the signature protected the request itself.
GET /api/photos HTTP/1.1
Host: api.example.com
Authorization: OAuth
oauth_consumer_key="abc123",
oauth_token="xyz789",
oauth_signature_method="HMAC-SHA1",
oauth_timestamp="1700000000",
oauth_nonce="r4nd0m",
oauth_version="1.0",
oauth_signature="kxQ8...%3D"
The signature covers method + URL + every parameter. Replay protection comes from the timestamp + nonce.
X / Twitter API v1.1, some legacy enterprise APIs, and Magento. Everything new uses OAuth 2.0.
OAuth 2.0 (RFC 6749, October 2012) is deliberately a framework, not a protocol. It defines roles, endpoints, and a small set of grant types — the rest is filled in by companion RFCs and profiles.
"OAuth 2.0 is bad, complicated, and fundamentally insecure." — Eran Hammer, "OAuth 2.0 and the Road to Hell", July 2012. He stepped down as lead author and removed his name from the spec.
His point: by removing signatures and shipping an extensible framework, the WG pushed security decisions onto every implementer — and most got them wrong.
| Grant | For | Status today |
|---|---|---|
| Authorization Code | Server & native apps with a user | Recommended |
| Implicit | JS in the browser, pre-PKCE | Removed in 2.1 |
| Resource Owner Password | First-party "trusted" apps | Removed in 2.1 |
| Client Credentials | Machine-to-machine (no user) | Recommended |
/authorize — user-facing, where consent happens/token — back-channel, exchanges codes/refresh for access tokens/jwks — public keys to verify signed tokens/.well-known/oauth-authorization-server — RFC 8414 metadata/revoke, /introspect, /userinfo (OIDC)Scope — string identifying a permission. Audience — which RS the token is for (RFC 8707). Claims — assertions inside a token. Consent screen — the AS UI users see.
| Token | What it represents | Lifetime | Sent to | Format |
|---|---|---|---|---|
| Access token | Permission to call the resource server | 5 min – 1 hour | Resource Server | Opaque or JWT |
| Refresh token | Permission to mint new access tokens | Hours – months | Authorization Server only | Opaque (always) |
| ID token | Statement about the user (OIDC) | 5–15 min | Stays with the client | Signed JWT (always) |
GET /api/me HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiI...
"Bearer" = whoever holds it can use it. Steal the token → impersonate the user until it expires.
/introspect on the AS to validate.Each /token call with grant_type=refresh_token issues a new refresh token and invalidates the old one. If two clients ever present the same refresh token, the AS treats it as theft and revokes the whole chain. Mandatory for public clients in 2.1.
The default OAuth flow for any app where a user is present. Everything else is a deprecated shortcut.
Steps 2–5 go through the user's browser. Anything visible — including the code — must be considered intercept-able.
Step 6 is server-to-server, TLS, with PKCE proof + (for confidential clients) a client secret. This is where the real token lives.
Splitting front and back channels means a leaked code alone is useless without the back-channel verifier & secret.
RFC 7636 (2015). Originally a fix for native apps that couldn't keep a client secret. In OAuth 2.1, PKCE is mandatory for every client — public or confidential.
# Client generates a high-entropy random string
code_verifier = base64url(random(32)) # ~43 chars
# And derives the challenge
code_challenge = base64url(SHA-256(code_verifier))
# Sent on /authorize (front-channel, visible)
GET /authorize?
response_type=code&
client_id=app123&
code_challenge=XYZ&
code_challenge_method=S256&
redirect_uri=...&state=...
# Sent on /token (back-channel, secret)
POST /token code=AUTH_CODE&
code_verifier=ORIGINAL_VERIFIER&
...
code_challenge alongside the issued code./token, the AS computes SHA-256(code_verifier) and compares.S256. plain is now forbidden.Hardcoding a per-app verifier instead of generating fresh per-request. PKCE is per-authorisation — re-use defeats it.
# Old, dangerous
GET /authorize?response_type=token&client_id=...
# returns: https://app/cb#access_token=eyJ...&token_type=Bearer
# Modern replacement
GET /authorize?response_type=code&code_challenge=...&...
POST /token
grant_type=password&
username=alice&password=hunter2&
client_id=... # never do this
That argument was used by every breached first-party app. Modern guidance (BCP 240): always launch a real browser, even for first-party apps. AppAuth libraries (iOS / Android) make this painless.
For service-to-service calls where no user is involved. The client authenticates with its own credentials and gets an access token bound to itself, not a user.
POST /token HTTP/1.1
Host: auth.example.com
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&
scope=invoices:read&
audience=https://api.billing.internal
# response
{ "access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600 }
client_secret_basic for sensitive M2M.Using client credentials on behalf of a user — the resulting token has no user identity, only "client X's token". Auditing breaks. Use Auth Code or token exchange instead.
RFC 8628 (2019). For devices with no browser or limited input — TVs, CLIs, IoT, terminal sessions. The flow you've used a hundred times: "go to github.com/login/device and enter ABCD-1234".
gh auth login, gcloud auth loginScope is the OAuth answer to "what can this token do?". Audience is "which API can it do it to?". Both must be checked.
# Google
scope=openid email profile
https://www.googleapis.com/auth/drive.readonly
# GitHub
scope=repo:status read:user user:email
# Generic verb:resource
scope=invoices:read invoices:write
payments:refund
# OIDC reserved
scope=openid # required for OIDC
scope=offline_access # request a refresh token
Strings are opaque to OAuth. Each AS / API defines its own vocabulary. Granularity is a UX vs security trade-off.
POST /token
grant_type=authorization_code&
code=...&
resource=https://api.invoices.example.com&
resource=https://api.payments.example.com
The resource parameter pins the issued token's aud claim. The RS must reject any token whose aud doesn't match its own URL.
Without audience binding, a token issued for a friendly RS can be replayed against an unrelated RS that trusts the same AS. This is the root cause of the "token passthrough" vulnerability you'll see again in Part 2 (MCP).
"App X wants to: read your invoices · refund payments" — the consent screen renders scope IDs into human strings the AS controls. Bad scope names = bad consent UX.
OAuth 2.0 says nothing about who the user is. OpenID Connect (OIDC) — finalised 2014 by the OpenID Foundation — is a thin identity layer on top of OAuth 2.0. It is what powers "Sign in with Google / Microsoft / Apple".
/.well-known/openid-configuration JSON document.sub, email, name, picture, etc.{
"iss": "https://accounts.google.com",
"sub": "1098765432109876543210",
"aud": "client123.apps.googleusercontent.com",
"iat": 1700000000,
"exp": 1700003600,
"email": "alice@example.com",
"email_verified": true,
"name": "Alice Example",
"picture": "https://lh3..."
}
openid + your API scopes in the same flow.$ curl https://accounts.google.com/.well-known/openid-configuration
{
"issuer": "https://accounts.google.com",
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"token_endpoint": "https://oauth2.googleapis.com/token",
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
"userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
"response_types_supported": ["code","token","id_token", ...],
...
}
An access token says "this client may call this API", not "this is user U". Always derive identity from the ID token's sub claim, never by introspecting an access token.
eyJhbGciOiJSUzI1NiIsImtpZCI6IjE0MyJ9 # header
.eyJpc3MiOiJodHRwczovL2lzcy5leGFtcGxlIiw # payload
ic3ViIjoiNDQiLCJhdWQiOiJhcGkiLCJleHAiOj
E3MDAwMDM2MDB9
.kxQ8... # signature
# Header
{ "alg":"RS256", "kid":"143", "typ":"JWT" }
# Payload
{ "iss":"https://iss.example",
"sub":"44", "aud":"api",
"iat":1700000000, "exp":1700003600,
"scope":"invoices:read",
"client_id":"app123" }
alg is what you expect (RS256, ES256). Reject none and reject HS256 if you only have a public key.kid. Cache, but rotate.iss matches the AS you trust.aud contains your RS identifier.exp is in the future and nbf in the past.scope covers the operation requested.alg:none tokens.aud check (confused deputy).OAuth 2.1 (draft-ietf-oauth-v2-1, in late-stage IETF review) is not a new protocol — it's RFC 6749 + a decade of errata + the security best practices (BCP 240) folded into one document.
plain method — banned (S256 only).| RFC | What it adds |
|---|---|
| RFC 8252 | OAuth for Native Apps (use the system browser) |
| RFC 8414 | Authorization Server Metadata (/.well-known/...) |
| RFC 7591 / 7592 | Dynamic Client Registration / Management |
| RFC 8628 | Device Authorization Grant |
| RFC 8693 | Token Exchange |
| RFC 8705 | mTLS Client Auth & Sender-Constrained Tokens |
| RFC 8707 | Resource Indicators (audience binding) |
| RFC 9068 | JWT Profile for Access Tokens |
| RFC 9126 | Pushed Authorization Requests (PAR) |
| RFC 9396 | Rich Authorization Requests (RAR) |
| RFC 9449 | DPoP — sender-constrained bearer tokens |
| RFC 9728 | OAuth 2.0 Protected Resource Metadata |
| BCP 240 | OAuth 2.0 Security Best Current Practice |
Bearer tokens have one fatal property: anyone who steals one can use it. Sender-constrained tokens fix that by binding the token to a key only the legitimate client controls.
POST /api/payment HTTP/1.1
Host: api.example.com
Authorization: DPoP eyJhbGciOiJSUzI1NiI...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2In0.
eyJqdGkiOiJlMS4uIiwiaHRtIjoiUE9TVCIsImh0dSI6
Imh0dHBzOi8vYXBpLmV4YW1wbGUuY29tL3BheW1lbnQiLA
AiaWF0IjoxNzAwMDAwMDAwfQ.signature
jti.cnf.jkt claim contains a hash of the public key — RS rejects mismatched proofs.cnf.x5t#S256 claim.| DPoP | mTLS | |
|---|---|---|
| Browser clients | yes | no |
| Mobile clients | yes | painful |
| Backend services | fine | preferred |
| Infra burden | low | PKI required |
Remote MCP servers exchange tokens over the public internet — Bearer tokens here are particularly tempting targets. The MCP authorisation profile recommends DPoP; we'll see this again in Part 2.
| Attack | How it works | Defence |
|---|---|---|
| Authorization Code Interception | Native app's custom URI scheme is registered by a malicious app on the same device; it receives the code. |
PKCE — stolen code can't be redeemed without the verifier. |
| CSRF on redirect_uri | Attacker tricks victim's browser into completing a flow against the attacker's account. | state parameter — random, bound to the user's session, validated on callback. |
| Open Redirector | A loose redirect_uri match (wildcards) lets an attacker exfiltrate the code via a controlled domain. |
Strict string match on redirect_uri (mandatory in 2.1). |
| Mix-up Attack | Client supports multiple AS; attacker tricks it into sending an honest AS's code to a malicious AS that already has a valid client_id. | iss parameter on the callback (RFC 9207). Match against the AS the flow started with. |
| Token Theft / Replay | Bearer access token leaks via logs, proxy, browser extension; attacker replays. | DPoP / mTLS sender-constrained tokens; short TTLs; refresh rotation. |
| Refresh Token Theft | Long-lived RT exfiltrated from a public client. | Rotation — old RT invalidated on every use; reuse detected as compromise. |
| Confused Deputy | Token issued for API A is replayed against API B that also trusts the AS. | Audience binding via Resource Indicators (RFC 8707) + aud validation. |
| Authorization Server Phishing | Attacker hosts a fake consent screen that looks like Google's. | Always launch the AS in the system browser (RFC 8252), not an embedded webview. |
| Click-Through Consent | Users approve everything without reading. | Granular scopes, plain-English consent strings, periodic re-consent. |
RFC 9700 (BCP 240) — "OAuth 2.0 Security Best Current Practice". Forty pages. Every implementer should have read it.
| Mechanism | What it does | Best for | Caveat |
|---|---|---|---|
| OAuth 2.0/2.1 | Delegated authorisation (token issuance for third-party apps) | API access on behalf of a user; M2M tokens; mobile/SPA | It's a framework, not a turnkey protocol — easy to misconfigure |
| OpenID Connect | Identity / SSO layer on top of OAuth 2.0 | "Sign in with..." flows, modern federated SSO | Adds JWT validation complexity |
| SAML 2.0 | XML-based browser SSO between IdP and SP | Enterprise SSO (Workday, Salesforce, internal apps) | XML-DSig is brittle; no native API delegation |
| API keys | Static shared secret per client | Simple machine-to-machine, internal services, getting started | No expiry, no scopes, painful rotation, no user context |
| HTTP Basic / Digest | Username + password per request | Internal admin tools behind a VPN | Sends credentials on every call; no MFA |
| WebAuthn / Passkeys | Phishing-resistant user authentication using device-bound keys | First-factor user login; replaces passwords | Authentication only — pair with OIDC for SSO + OAuth for API delegation |
| Macaroons / Biscuit | Capability tokens with caveats (attenuation in the client) | Fine-grained delegation chains, offline-friendly | Niche; little ecosystem support |
Every modern login button is OAuth 2.0 + OIDC under the hood. The endpoints differ; the flow is the same.
Discovery:
https://accounts.google.com/.well-known/openid-configuration
Authorize:
https://accounts.google.com/o/oauth2/v2/auth
Token:
https://oauth2.googleapis.com/token
JWKS:
https://www.googleapis.com/oauth2/v3/certs
Scopes: openid email profile + Google API scopes
Authorize: https://github.com/login/oauth/authorize
Token: https://github.com/login/oauth/access_token
Device: https://github.com/login/device/code
User: https://api.github.com/user
Scopes: repo, read:user, user:email, ...
Note: opaque tokens (not JWTs); separate fine-grained PATs.
Discovery:
https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration
Authorize: .../oauth2/v2.0/authorize
Token: .../oauth2/v2.0/token
Scopes: openid offline_access User.Read https://graph.microsoft.com/.default
Multi-tenant via {tenant}=common or {tenant}=organizations
Part 2 of this deck covers the full provider landscape — Auth0, Okta, AWS Cognito, Stytch, WorkOS, Clerk, Descope, plus the open-source side: Keycloak, Authentik, ZITADEL, Ory Hydra, Authelia, Logto, SuperTokens — and how to wire each into a remote MCP server.
OAuth is a "15 lines of code" demo and a "thousand-line spec to get right" reality. Use a library that already passes the conformance suite.
oauth4webapi, oidc-client-ts, Auth0 SPA SDKopenid-client, passport-oauth2authlib, requests-oauthlibgolang.org/x/oauth2 + github.com/coreos/go-oidcoauth2 crate, openidconnectjose, express-oauth2-jwt-bearerauthlib ResourceProtector, PyJWT + JWKS cachegithub.com/golang-jwt/jwt + github.com/MicahParks/keyfuncjsonwebtoken, axum-jwksopenid.net/certification — the gold standard.oauth-tools / oauth-debugger in dev to inspect what flows your AS actually does.| Your client is... | User present? | Use this flow | Notes |
|---|---|---|---|
| Server-side web app (Express, Rails, Django) | yes | Authorization Code + PKCE | Confidential client; can keep a client secret. |
| SPA (React, Vue, Angular in the browser) | yes | Authorization Code + PKCE | Public client; no secret. Or use a backend-for-frontend. |
| Mobile app (iOS, Android) | yes | Authorization Code + PKCE | System browser via AppAuth; never an embedded webview (RFC 8252). |
| CLI / desktop tool | yes | Auth Code + PKCE (loopback) or Device Code | Loopback works on dev machines; device code is best when no browser is local. |
| Smart TV / IoT / kiosk | yes (on a phone) | Device Authorization | RFC 8628. |
| Cron job / backend service | no | Client Credentials | Use private_key_jwt or mTLS for stronger client auth. |
| Service-A calling Service-B with user context | indirectly | Token Exchange (RFC 8693) | Preserves the original user identity through the chain. |
| Local MCP server (stdio) | n/a | no OAuth | Process boundary is local; trust the launching app. |
| Remote MCP server (HTTP) | yes | Auth Code + PKCE + Resource Indicators (+ DPoP) | See Part 2 of this deck. |
| Param | Required | Notes |
|---|---|---|
response_type | yes | Always code |
client_id | yes | Issued by AS |
redirect_uri | yes | Pre-registered, exact match |
scope | yes | Space-separated |
state | CSRF | Random per-request |
code_challenge | PKCE | S256 hash |
code_challenge_method | yes | S256 |
nonce | OIDC | Reflected in ID token |
resource | RFC 8707 | Pin token audience |
POST /token HTTP/1.1
Authorization: Basic base64(client_id:secret) # confidential
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTH_CODE&
redirect_uri=https://app/cb&
code_verifier=ORIGINAL_VERIFIER
invalid_request — missing/bad parameterinvalid_client — auth failedinvalid_grant — code/refresh token rejectedunauthorized_client — client not allowed this grantunsupported_grant_typeinvalid_scopeauthorization_pending / slow_down — device code pollingHTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api",
error="invalid_token",
error_description="The access token expired"
state → CSRF on the callback. Pick a UUID per flow, store in session, compare.sub.localStorage → XSS exfiltrates them. Prefer HttpOnly cookies + a backend-for-frontend.redirect_uri → https://*.example.com is an open redirector. Register exact strings.aud → confused deputy. Always pin to your RS URI.kid.scope without checking it → you've reinvented "any token works".RFC 6749 (core) · RFC 6750 (Bearer) · RFC 7636 (PKCE) · RFC 8414 (AS Metadata) · RFC 8628 (Device) · RFC 8707 (Resource Indicators) · RFC 9068 (JWT AT) · RFC 9449 (DPoP) · RFC 9700 / BCP 240 (Security BCP) · OpenID Connect Core 1.0 · oauth.net · openid.net
"OAuth for MCP Servers & the Identity Provider Landscape"
You don't implement OAuth — you configure it. Pick a conformant library, pin every parameter the spec lets you pin, and read BCP 240.