OAuth 2.0 and OpenID Connect
OAuth 2.0 is a delegated authorization protocol — letting an app act on behalf of a user without giving it the user’s password. OIDC is a thin identity layer on top.
The four roles
- Resource owner — the user.
- Client — your app, asking for permission.
- Authorization server — the identity provider (Google, Auth0, Keycloak, your own).
- Resource server — the API the client wants to call (often co-located with auth server, sometimes separate).
The flows (grant types)
| Flow | Use case | Status |
|---|---|---|
| Authorization Code + PKCE | Web apps, mobile apps, SPAs | Recommended for almost everything |
| Client Credentials | Server-to-server, no user | Recommended for M2M |
| Refresh Token | Renew access without re-prompting | Used alongside others |
| Implicit | Old SPA flow, token in URL fragment | Deprecated — replaced by Auth Code + PKCE |
| Resource Owner Password | Direct username/password to app | Deprecated — only for legacy migration |
| Device Code | TVs, CLI on devices without browser | Niche but useful |
Authorization Code flow
The flow you should use 95% of the time.
1. User clicks "Sign in with X" in your app
2. App redirects to authz server with:
client_id, redirect_uri, scope, state (CSRF random), code_challenge (PKCE)
3. User authenticates with X, approves scopes
4. Authz server redirects to your redirect_uri with: code, state
5. App verifies state matches, then POSTs to token endpoint:
code, code_verifier (PKCE), client_id, [client_secret]
6. Authz server returns: access_token, refresh_token, id_token (if OIDC)
7. App calls resource server with: Authorization: Bearer <access_token>
The code is a short-lived (~30s), single-use proof. The token exchange happens server-side, so the access token never appears in URLs or browser history.
PKCE — Proof Key for Code Exchange
Mandatory for public clients (SPAs, mobile), recommended for confidential clients too.
import secrets, hashlib, base64
# 1. Client generates a verifier
code_verifier = secrets.token_urlsafe(64)
# 2. Computes challenge = base64url(sha256(verifier))
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b"=").decode()
# 3. Sends challenge in /authorize, then verifier in /token
# Authz server checks sha256(verifier) == challenge
Why: prevents code interception. Even if an attacker grabs the code, they don’t have the verifier and can’t exchange it.
Client Credentials (M2M)
# Server A → Server B, no user involved
import requests
resp = requests.post(
"https://auth.example.com/oauth/token",
data={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"scope": "read:users",
},
)
access_token = resp.json()["access_token"]
Use for: cron jobs, internal services, anywhere a real user isn’t part of the flow.
Refresh tokens
Access tokens are short-lived (5–60 min). Refresh tokens are long-lived (days/weeks). When the access token expires, use the refresh token to get a new one without re-authenticating the user.
resp = requests.post(token_url, data={
"grant_type": "refresh_token",
"refresh_token": stored_refresh_token,
"client_id": CLIENT_ID,
})
Best practice: refresh token rotation — every refresh issues a new refresh token, old one is invalidated. If a stolen refresh token is used, the legitimate user’s next refresh fails (they get re-auth’d) and you can detect the breach.
OpenID Connect
OAuth 2.0 is for authorization. It doesn’t tell you who the user is. OIDC adds an id_token (a JWT) with standard claims:
{
"iss": "https://auth.example.com",
"sub": "248289761001", // unique user ID at this issuer
"aud": "your-client-id",
"iat": 1716000000,
"exp": 1716003600,
"email": "alice@example.com",
"email_verified": true,
"name": "Alice"
}
To get the id_token, request the openid scope:
scope=openid email profile
id_token is for your app, identifying the user. access_token is for the resource API. Don’t conflate them.
Validating tokens (server side)
For each request with Authorization: Bearer <token>:
from authlib.jose import jwt, JsonWebKey
from authlib.jose.errors import JoseError
# Fetch JWKs from issuer (cache!)
jwks = requests.get("https://auth.example.com/.well-known/jwks.json").json()
claims = jwt.decode(
token,
JsonWebKey.import_key_set(jwks),
claims_options={
"iss": {"essential": True, "value": "https://auth.example.com"},
"aud": {"essential": True, "value": "your-api"},
"exp": {"essential": True},
},
)
claims.validate()
user_id = claims["sub"]
Always validate:
- Signature — proves issuer signed it.
iss— issuer matches what you trust.aud— audience matches your API. Without this, a token issued for app X can be replayed against API Y.exp— not expired.nbf— not used before its valid window.
Cache the JWKs (5 min TTL is typical), and handle key rotation: if signature fails, refetch JWKs once before rejecting.
Access tokens: JWT vs opaque
| Property | JWT | Opaque |
|---|---|---|
| Validation | Stateless (verify signature) | Calls auth server’s introspection endpoint |
| Revocation | Can’t revoke before expiry without blocklist | Immediate (invalidate at server) |
| Performance | Fast, no auth-server roundtrip | Slower per request |
| Resource server changes | None | Needs network access to auth server |
For high-throughput APIs, JWT is common but you accept revocation lag. For sensitive APIs, opaque + introspection is safer.
Common pitfalls
- Skipping audience validation — token-replay across APIs.
alg: noneaccepted — see 06_jwt_pitfalls.md.- Implicit flow — token in URL fragment, exposed in referrer headers, browser history. Don’t.
- Storing tokens in localStorage for SPAs — readable by XSS. Use
HttpOnlycookie if your architecture allows; otherwise accept the risk and lock down XSS hard. - Long-lived access tokens — 24h access tokens with no rotation = 24h breach window if stolen. Keep access tokens short, use refresh.
- Trusting the
id_tokenfor API calls — id_token is for your app to identify the user; resource server should requireaccess_token.
Interview angle
- Q: “What’s OAuth 2.0?” — delegated authorization.
- Q: “Authorization code vs implicit?” — code is server-side exchange, implicit puts token in URL fragment; implicit is deprecated.
- Follow-up: “What’s PKCE and why is it needed?” — proof-of-possession via verifier+challenge; mandatory for public clients to prevent code interception.
- Follow-up: “Difference between OAuth and OIDC?” — OAuth is authz, OIDC adds identity (
id_token).