CyberNugget

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 state costs 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"
}
  • -u sends 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.
  • scope may come back narrower than requested, and there's no refresh_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, exp and scope. Skip aud and 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.

Deep dive 1

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 https links (universal or app links) over custom schemes (RFC 8252).
  • No logging of query strings on callback routes, and Referrer-Policy: no-referrer there (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_uri says, so the AS must match it exactly.
  • PKCE turns a stolen code into a useless string; state stops 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
Deep dive 2

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 cnf claim, 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_jwt proves 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.