backend / security / 05_oauth2_oidc.md

OAuth 2.0 and OpenID Connect

5 min read source

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: none accepted — 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 HttpOnly cookie 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_token for API calls — id_token is for your app to identify the user; resource server should require access_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).

See 06_jwt_pitfalls.md, 11_authentication/.