L23 · OAuth 2.0 Explained
Module 4: Identity and Access · ~20 min · Lesson 23 of 71
🎯 What you'll learn
- The four OAuth roles, and why OAuth is about delegated authorization, not login
- How the authorization code flow with PKCE works, step by step, and why two older grants are retired
- How the client credentials grant lets a service (like an Airflow DAG) get its own token
- What scopes and audience mean, and what a gateway such as Kong, or the API itself, must check before trusting a token
🔥 Why this matters
In April 2022, GitHub found an attacker downloading private repositories from dozens of organizations, npm among them. No passwords were stolen: the attacker had OAuth tokens that users had granted to two trusted integrations, Heroku and Travis CI, taken from those vendors' side. One downloaded repo held an AWS key, which led into npm's infrastructure. Most requests that pass through an API gateway such as Kong carry a token like these, so it pays to know how they're issued and checked.
1. The valet key: delegated authorization
Some cars come with a valet key. It starts the engine but won't open the trunk or the glovebox. The parking attendant gets just enough access for the job, and you never hand over the key that does everything.
OAuth 2.0 (RFC 6749, 2012) is a valet key for APIs: a standard way for a user to give an application limited access to their data at another service without giving it their password. It has four roles:
| Role | Plain meaning | Example |
|---|---|---|
| Resource owner | Whoever owns the data and can grant access to it | You, the customer |
| Client | The app that wants access | A budgeting app |
| Authorization server (AS) | Logs the owner in, asks for consent, issues tokens | The bank's identity provider |
| Resource server (RS) | The API holding the data; it accepts tokens | The bank's accounts API, behind Kong |
[ Resource owner ]
│ 1 "let the budgeting app read
│ my transactions"
▼
( Authorization server ) ──2 access token──▶ ( Client app )
logs the owner in, │
asks for consent │ 3 GET /txns
│ Bearer <token>
▼
( Resource server )
checks the token,
returns the data
The valet key is an access token: a credential for one specific grant (this client, this API, these actions, until this time). Most are bearer tokens (RFC 6750): whoever holds one can use it, like cash, which is why the GitHub tokens were so valuable. A refresh token is a longer-lived credential the client trades for new access tokens without asking the user again.
OAuth is not authentication. An access token is addressed to the API and says what the holder may do there; it isn't built to tell the client who the user is. Proving identity is the job of OpenID Connect (OIDC), which adds an ID token on top (L24). In L21's terms, OAuth answers "what may this app do?"
2. The authorization code flow with PKCE
Your team builds an internal web portal, with a small backend, where agents view policies. It needs a token for the policy API on behalf of whoever's logged in. The standard answer is the authorization code flow with PKCE:
Browser Client app Auth server API
│ │ │ │
│ │ 1 make random V, │ │
│ │ C=SHA256(V), state │ │
│◀─2 redirect ────│ │ │
│──3 GET /authorize?───────────────────▶│ │
│ response_type=code &client_id │ │
│ &redirect_uri &scope &state │ │
│ &code_challenge=C │ │
│◀═4 user logs in + consents ══════════▶│ │
│◀─5 302 redirect_uri?code=K&state ─────│ │
│──6 code+state──▶│ │ │
│ │ 7 check state │ │
│ │──8 POST /token ────▶│ │
│ │ code=K, V, │ │
│ │ redirect_uri │ 9 SHA256(V) │
│ │ │ == C ? │
│ │◀─10 access token ───│ │
│ │──11 GET /policies, Bearer ───────────▶│
Each piece blocks a specific attack:
- Login happens at the AS (step 4). The client never sees the password, and the AS enforces MFA (L21).
- A one-time code, not a token (step 5). Steps 2 to 6 run through the browser (the front channel), where URLs leak into history, logs and Referer headers. The token itself travels server-to-AS in step 8 (the back channel).
- PKCE (Proof Key for Code Exchange, RFC 7636). The client invents a random code_verifier (V) and sends only its hash, the code_challenge (C). To redeem the code it must show V, so a thief who copies the code from a log gets refused.
- state. A random value tied to the user's session. A callback with a state the client didn't
create means someone is slipping their own code into your session (a CSRF, L33). RFC 9700 lets
PKCE cover this when the AS supports it, but keeping
statecosts nothing. - redirect_uri, matched exactly. The AS sends codes only to URIs registered in advance, compared as exact strings (first deep dive).
A confidential client (a backend that can keep a secret) also authenticates itself in step 8. A public client (a SPA or mobile app, whose secrets can be extracted) can't, which is why PKCE was invented. RFC 9700 says public clients MUST use PKCE; for confidential ones it's RECOMMENDED.
3. Two retired grants: implicit and password
RFC 9700, the OAuth 2.0 Security Best Current Practice (BCP 240, January 2025), retires two original shortcuts:
| Grant | How it worked | Why it's retired | RFC 9700 |
|---|---|---|---|
| Implicit | AS put the access token straight into the redirect URL | Token exposed in the front channel; can't be bound to the client | SHOULD NOT use |
| Resource owner password | The app collected your password and sent it to the AS | Hands the password to the app and bypasses the AS's MFA | MUST NOT use |
OAuth 2.1 folds this into one spec: no implicit or password grant, PKCE for every code flow, exact redirect matching. As of November 2026 it's still an IETF Internet-Draft (draft -16, September 2026), not an RFC, but most IdPs already follow it.
4. Client credentials: when no user is involved
A nightly Airflow DAG calls GET /v1/quotes/summary through Kong. There's no person to log in.
The client credentials grant (RFC 6749 §4.4) covers this: the client authenticates as
itself and gets a token for its own access.
( Airflow DAG ) ( Auth server )
│ 1 POST /token │
│ grant_type= │
│ client_credentials │
│ + client auth, scope │
│───────────────────────────────▶│ 2 check client,
│◀──────3 access token (15 min)──│ allowed scopes
│
│ 4 GET /v1/quotes/summary, Bearer
▼
( Kong ) ──5 checks iss, aud, exp, scope──▶ ( quotes-api )
The client's credential is usually a client secret, so treat it like any secret (L19): keep it in a secrets backend, never in DAG code. Give each job its own client so you can scope, audit and revoke it alone. No refresh token should be issued here; the client just asks again. The second deep dive covers options with no stored secret.
5. Scopes, audience, and what the API must check
A scope names a kind of access the token grants, such as quotes:read. Scopes are a ceiling,
not a full permission check: policies:read lets the client read policies in general, but
whether this agent may read this policy (PL-100246) is still the API's decision (L34, L40).
The audience (aud claim) names which API a token is for. If the quotes API doesn't check it,
a token minted for the payments API works there too. RFC 9700 says access tokens SHOULD be
audience-restricted. RFC 8707 adds a resource parameter to request one; many IdPs use their own
audience parameter or derive it from the scope instead.
Before trusting a token, the resource server, or Kong in front of it, checks:
1 signature signed by the IdP's key (JWKS, L16)
2 iss issued by the IdP we trust
3 aud issued for THIS API
4 exp / nbf not expired, not used before its start
5 scope covers this route and method
─────────────────────────────────────────────────
then, in the service: may this caller touch THIS object?
JWT access tokens (profiled in RFC 9068) are checked locally; opaque tokens via the AS's introspection endpoint (RFC 7662). JWT pitfalls are L25.
🧪 Worked example: a client-credentials token through Kong
Step 1: the DAG asks for a token (made-up IdP and client; the secret comes from the environment):
$ curl -s https://idp.example.com/oauth2/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=client_credentials \
-d scope=quotes:read \
-d resource=https://quotes-api.example.com
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6...",
"token_type": "Bearer",
"expires_in": 900,
"scope": "quotes:read"
}
-usends the client ID and secret as HTTP Basic auth (client_secret_basic).expires_in: 900: 15 minutes, which limits what a stolen token is worth.scopemay come back narrower than requested, and there's norefresh_token(correct here).
Step 2: what's inside (the decoded JWT payload):
{
"iss": "https://idp.example.com",
"sub": "airflow-quotes-dag",
"aud": "https://quotes-api.example.com",
"scope": "quotes:read",
"iat": 1793930400,
"exp": 1793931300,
"jti": "7f3c9a2e-0b1d-4e55-9c1a-2d8e6f4b1a00"
}
Step 3, vulnerable: Kong accepts any token the IdP signs. This decK snippet uses Kong's openid-connect plugin (an Enterprise plugin) with only the issuer set:
# VULNERABLE: any valid token from this IdP gets in
plugins:
- name: openid-connect
route: quotes-read
config:
issuer: https://idp.example.com/.well-known/openid-configuration
auth_methods: [bearer]
Signature, issuer and expiry are checked, so it looks secure. But a token the payments team got
for payments-api, or a sandbox client's quotes:write-only token, passes too: every client of
the IdP can call quotes-api. Kong's open-source jwt plugin has a similar gap. It verifies the
signature against the consumer credential matching iss, checks exp/nbf only if listed in
claims_to_verify (no default), and has no setting for aud or scope, so the service must.
Step 4, fixed: require this API's audience and scope.
plugins:
- name: openid-connect
route: quotes-read
config:
issuer: https://idp.example.com/.well-known/openid-configuration
auth_methods: [bearer]
issuers_allowed: [https://idp.example.com]
audience_required: [https://quotes-api.example.com]
scopes_required: [quotes:read]
Now the payments token fails on audience and the sandbox token on scope. In scopes_required,
list items are alternatives (OR); to require two together, put them in one item separated by a
space ("quotes:read quotes:export"). Kong checks the token; quotes-api still checks which quotes
this caller may see, and a network policy stops pods calling it around Kong (L05, L09).
🛠 Try it (optional, ~5 min)
Compute a PKCE pair locally and compare it with RFC 7636's Appendix B test vector.
# pkce.py run with: python3 pkce.py
import base64, hashlib, secrets
def b64url(b):
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
V = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" # RFC's V
C = b64url(hashlib.sha256(V.encode()).digest())
print("challenge:", C)
RFC_C = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
print("RFC match:", C == RFC_C)
v2 = b64url(secrets.token_bytes(32)) # a fresh one
print("fresh V :", v2, len(v2))
print("fresh C :", b64url(hashlib.sha256(v2.encode()).digest()))
challenge: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
RFC match: True
fresh V : ZS-ESkb7CxOgsHdGlu2dZCJSXRs0SMJioN86ThlppVI 43
fresh C : w3Pv6HjTL9-ewzSFTXkDCkPIRTRtwuT4UZbUU-F2eMo
The same check in the shell (works with Homebrew OpenSSL and with macOS's built-in LibreSSL at
/usr/bin/openssl):
$ printf '%s' 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' \
| openssl dgst -sha256 -binary | openssl base64 -A \
| tr '+/' '-_' | tr -d '='
E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
Notice: the verifier is 43 characters (RFC 7636 allows 43 to 128), in base64url (- and _
for + and /, no = padding). C is a one-way hash, so seeing it in a URL reveals nothing about
V. Your fresh values will differ every run.
🧠 Key takeaways
- OAuth 2.0 is delegated authorization: a limited, expiring token instead of a password. It isn't a login protocol (that's OIDC, L24).
- For apps with users, use the authorization code flow with PKCE,
state, and exact redirect URIs. Implicit and password grants are retired (RFC 9700). - For service-to-service calls, use client credentials: one client per job, narrow scope, short tokens.
- The gateway (Kong here) or the API must verify signature,
iss,aud,expandscope. Skipaudand any token from your IdP works on any API; scopes are a ceiling, so the API still checks each object.
📖 Words to know
| Term | Meaning |
|---|---|
| OAuth 2.0 | A framework for giving an app limited, delegated access to an API without sharing a password |
| Authorization server (AS) | The service that authenticates the owner and issues tokens |
| Resource server (RS) | The API that accepts access tokens |
| Access token | A credential representing a specific grant: who, which API, which scopes, until when |
| Refresh token | A longer-lived credential used to get new access tokens |
| Authorization code | A short-lived, one-time code passed through the browser and exchanged for tokens |
| PKCE | Proof Key for Code Exchange: a hashed secret that ties a code to the client that asked for it |
| state | A random value that ties an authorization response to the session that started it |
| Client credentials grant | A grant where a service authenticates as itself, with no user |
| Scope | A named kind of access a token grants, such as quotes:read |
Audience (aud) |
The API a token was issued for |
⏭ Next time
L24: OpenID Connect and SSO. How an ID token proves who logged in, how one login gets you into many apps, and what's inside an IdP's discovery document.
OAuth attacks: redirect URI tricks, stolen codes and consent phishing
Most OAuth breaches aren't broken cryptography. They're a code or token sent somewhere it shouldn't go, or a user approving something they didn't understand. Here are the three big families and the defenses for each.
1. Redirect URI tricks
The AS sends the authorization code to the redirect_uri. If an attacker can make that URI point
at a server they control, the code goes to them. That's possible when the AS matches redirect URIs
loosely:
| Registered | AS rule | Attacker sends | Result |
|---|---|---|---|
https://app.example.com/cb |
prefix match | https://app.example.com.evil.example/cb |
Code goes to the attacker's domain |
https://*.example.com/cb |
wildcard | https://old-promo.example.com/cb |
A forgotten subdomain taken over (L08) receives codes |
https://app.example.com/ |
path prefix | https://app.example.com/go?url=https://evil.example |
An open redirect on the client forwards the code |
The fix is boring and absolute: RFC 9700 says the AS MUST compare redirect URIs by exact string
match (the only exception is the port of a localhost redirect for native apps). As the client
owner, register full URIs, no wildcards, and remove open redirects from your callback pages.
2. Stolen and injected codes
Codes leak more often than you'd think. Here's the classic mobile attack that PKCE was invented for:
1 Real app and a malicious app both register the
custom scheme myapp://callback on the phone
│
2 User logs in at the AS; AS redirects to
myapp://callback?code=K (OS may pick either app)
│
3 Malicious app receives code K
│
4 Malicious app POSTs code K to /token ──▶ AS
│
WITHOUT PKCE: AS returns tokens ✗
WITH PKCE: AS asks for code_verifier V, which
only the real app has → rejected ✓
Codes also leak through server logs that record full callback URLs, browser history, analytics
scripts on the callback page, and Referer headers sent to third-party resources.
Defenses, layered:
- PKCE makes a stolen code useless on its own.
- One-time, short-lived codes. RFC 6749 recommends a lifetime of at most 10 minutes. If a code is used twice, the AS must refuse it and should revoke tokens already issued from it.
- Exact redirect matching, as above. Native apps should prefer claimed
httpslinks (universal or app links) over custom schemes (RFC 8252). - No logging of query strings on callback routes, and
Referrer-Policy: no-referrerthere (L37).
The reverse attack is code injection: the attacker starts a flow on their own account, stops
before redeeming the code, and gets the victim's browser to deliver it to the client. The victim
ends up linked to the attacker's account, for example uploading documents into it. state (and
PKCE, which binds the code to the session that started the flow) blocks this.
3. Consent phishing (illicit consent grants)
This one needs no bug at all. The attacker registers a real OAuth app at a real, popular IdP, names it something like "Secure Document Viewer", and emails a link that opens the IdP's genuine login page. The URL bar is correct. MFA works normally. Then a consent screen asks for:
Secure Document Viewer (unverified) wants to:
✓ Read your mail
✓ Read and write all files you can access
✓ Maintain access to data you have given it access to
[ Cancel ] [ Accept ]
One click on Accept, and the attacker holds a refresh token for your mailbox and files. Depending
on the IdP and app type, a password reset may not revoke it; revoke the grant itself. The last line ("maintain
access") is the offline_access scope, which is what yields a refresh token.
Defenses are mostly admin settings:
- Restrict user consent: users may approve only verified publishers and low-risk scopes, and anything else goes to an admin consent workflow.
- Review OAuth app grants regularly, like any other access review (L13): which apps hold which scopes for whom, and when they were last used.
- Alert on new grants with high-risk scopes such as mail, files, or directory read.
- Teach the tell: a genuine login page followed by a broad permission request for an app you've never heard of.
Mapping it back to a typical stack
When you register an OAuth client for an internal app, the checklist is short: exact redirect URIs
(no wildcards, no http except localhost), PKCE on, the smallest scopes that work, short access
tokens, and refresh token rotation for public clients (RFC 9700 requires rotation or
sender-constraining for them). For the GitHub incident in the main lesson, the lesson is on the
other side: third-party integrations hold long-lived tokens to your data, so inventory them and
revoke what you don't use.
🧠 Extra takeaways
- Codes and tokens go wherever
redirect_urisays, so the AS must match it exactly. - PKCE turns a stolen code into a useless string;
statestops someone injecting their own code. - Consent phishing uses the real login page and real MFA; restrict user consent and review grants.
- Revoking a grant, not resetting a password, is what kills a stolen refresh token.
| Term | Meaning |
|---|---|
| Open redirect | A page that forwards the browser to any URL given in a parameter |
| Code injection | Getting a victim's client to redeem an attacker's authorization code |
| Consent phishing | Tricking a user into granting a malicious OAuth app access through a genuine consent screen |
offline_access |
A scope that asks for a refresh token, so access continues after the user leaves |
| Refresh token rotation | Issuing a new refresh token at each use and invalidating the old one |
Service-to-service: client credentials vs workload identity vs mTLS
Client credentials solves "a service needs a token," but in its simplest form it adds a new problem: a client secret that lives somewhere, gets copied into config, and works for anyone who finds it. L19's lesson applies: the best secret is one you don't have to store. Here are the options, from most secret to least.
Option 1: client secret
The DAG sends client_id and client_secret to the token endpoint, as in the main lesson. It's
simple and universally supported. The weaknesses: the secret is static, it's sent on every token
request, and anyone with read access to the Airflow connection or the k8s Secret can mint tokens.
If you use it, keep it in a secrets backend, rotate it, and give each job its own client.
Option 2: private key JWT (signed client assertion)
Instead of sending a secret, the client signs a short-lived JWT with its own private key, and the
AS checks it with the registered public key (RFC 7523; the method is called private_key_jwt).
$ curl -s https://idp.example.com/oauth2/token \
-d grant_type=client_credentials \
-d scope=quotes:read \
-d client_assertion_type=urn:ietf:params:oauth:\
client-assertion-type:jwt-bearer \
-d client_assertion="$ASSERTION"
(The client_assertion_type value is one string,
urn:ietf:params:oauth:client-assertion-type:jwt-bearer, wrapped here to fit.) The assertion's
payload looks like this:
{
"iss": "airflow-quotes-dag",
"sub": "airflow-quotes-dag",
"aud": "https://idp.example.com/oauth2/token",
"exp": 1793930460,
"jti": "b41e7d0c-5f2a-4c8e-a913-6d0f2e7c55aa"
}
The AS stores only a public key, so a breach there leaks nothing usable. The private key never
crosses the network, and each assertion expires in a minute and carries a unique jti (the replay
defenses from L17). You still have a private key to protect and rotate.
Option 3: workload identity federation
On EKS, the platform already gives every pod a signed identity: a projected service account
token, a short-lived JWT from the cluster's OIDC issuer saying "I am service account
quotes-sync in namespace batch." IRSA and Pod Identity (L19) trade that token for AWS
credentials. The same idea works with an IdP that supports federated credentials: the pod sends
its Kubernetes token as the client_assertion, and the IdP trusts the cluster's issuer for that
client.
┌─ EKS pod (sa: batch/quotes-sync) ─┐
│ kubelet mounts a projected SA │
│ token, refreshed automatically │
└───────────────┬───────────────────┘
│ 1 POST /token, client_assertion =
│ k8s SA token (aud = the IdP)
▼
( IdP ) 2 verify with the cluster's OIDC keys;
sub must be system:serviceaccount:
batch:quotes-sync
│ 3 access token, aud quotes-api,
│ scope quotes:read, 15 min
▼
( Kong ) ──4 iss, aud, exp, scope──▶ ( quotes-api )
Nothing long-lived is stored anywhere: the platform rotates the pod's token, and the trust rule names exactly one service account. It's the same pattern as GitHub OIDC to AWS in L19. Support and setup vary by IdP, so check yours before you design around it.
Option 4: mutual TLS
Mutual TLS (mTLS) means both sides present certificates in the TLS handshake (L10, L11). It shows up in two different roles, and it's worth not mixing them up:
- mTLS to the AS (RFC 8705): the client authenticates to the token endpoint with its
certificate instead of a secret, and the AS can issue certificate-bound tokens. The token
carries a hash of the client's certificate (the
cnfclaim,x5t#S256), and the resource server accepts it only over a TLS connection using that certificate. A stolen token is useless without the private key. - mTLS between services (for example, a service mesh or Kong-to-upstream mTLS): proves which workload is connecting. It says nothing about scopes or which user the call is for, so it complements tokens rather than replacing them.
Where mTLS is impractical (browsers, some proxies), DPoP (RFC 9449) gives a similar sender-constraint at the HTTP layer: the client signs a small proof with its own key on each request.
Comparing them
| Approach | What's stored | If stolen | Rotation |
|---|---|---|---|
| Client secret | Shared secret, in config | Attacker mints tokens until you rotate | Manual |
| Private key JWT | Private key on the client | Attacker mints tokens until key revoked | Key rotation, AS keeps only public keys |
| Workload identity | Nothing long-lived | A pod token lasting minutes to an hour, valid only for the intended audience | Automatic |
| mTLS + bound tokens | Cert and private key | Stolen tokens alone are useless | Cert lifecycle (often automated) |
For a new service on EKS, a reasonable order of preference is workload identity, then mTLS or private key JWT, then a client secret as a last resort. Whatever you pick, the token rules from the main lesson don't change: narrow scope, the right audience, short lifetime, checked at Kong and in the service.
🧠 Extra takeaways
- A client secret is a static secret; prefer methods where nothing long-lived is stored or sent.
private_key_jwtproves possession of a key without sending it; the AS keeps only public keys.- Workload identity reuses the platform's own signed identity (k8s SA tokens, GitHub OIDC).
- mTLS can authenticate the client and bind the token to it; service-mesh mTLS identifies the workload but doesn't replace scopes.
| Term | Meaning |
|---|---|
| Client assertion | A signed JWT a client sends to prove its identity at the token endpoint |
private_key_jwt |
Client authentication using a client assertion signed with the client's private key |
| Workload identity federation | Using a platform-issued identity (k8s, CI) as the credential for an IdP or cloud |
| Projected service account token | A short-lived, audience-bound JWT that Kubernetes mounts into a pod |
| Certificate-bound token | An access token usable only over TLS with the client certificate it was issued to |
| DPoP | Demonstrating Proof of Possession: binding a token to a key the client proves on each request |
The quiz for this lesson and the Tests section need JavaScript.