Skip to content
Published on

OAuth 2.0 & OIDC Deep Dive — Authorization Code, PKCE, JWT, DPoP, FAPI (2025)

Authors

TL;DR

  • OAuth 2.0 is an authorization protocol, not authentication. It delegates "this app may read my Gmail" — it does not prove "who I am".
  • OpenID Connect (OIDC) adds an authentication layer on top of OAuth 2.0, issuing an ID Token (JWT) that asserts user identity.
  • Four flows: Authorization Code (+ PKCE), Client Credentials, Device Code, Refresh Token. Implicit and Password are deprecated in OAuth 2.1.
  • PKCE started as a mobile public-client extension but is mandatory for all clients in OAuth 2.1. It defends against authorization code interception.
  • JWT: Base64URL-encoded header.payload.signature. Self-contained. Fast to verify, hard to revoke.
  • OIDC Discovery: /.well-known/openid-configuration advertises endpoints, keys, algorithms.
  • JWKS: distributes signing public keys as JSON. Supports key rotation.
  • Token binding: tie tokens to a specific client/TLS connection. DPoP (proof-of-possession) and mTLS binding.
  • FAPI: financial-grade profile with PAR, JARM, mandatory token binding.

1. Origins

1.1 Dark Ages (mid-2000s)

If a web app wanted to read your Gmail in 2006:

App: "Give me your Gmail password."
User: "..."
App: logs in via IMAP, reads inbox.

The app permanently stored the password, had full access (including delete), and revocation meant changing the password. 2FA broke everything.

1.2 OAuth 1.0 (2007)

Twitter, Flickr, Ma.gnolia designed a delegation protocol with four roles: Resource Owner, Client, Resource Server, Authorization Server. The user approves at the AS, the AS issues a token, the client calls APIs with the token. Revocable, scoped, per-app.

OAuth 1.0 required HMAC signing on every request — developers found it too complex.

1.3 OAuth 2.0 (2012)

RFC 6749. Dropped signatures in favor of TLS; introduced bearer tokens; defined multiple flows; made the framework extensible. The trade-off: security responsibility shifted to implementers. OAuth 2.0 is secure when used correctly but easy to misuse.

1.4 OIDC (2014)

People used OAuth for authentication ("Log in with Facebook"), but an access token proves nothing about identity. OIDC adds an ID Token — a signed JWT asserting "user U logged in at time T at issuer I".

1.5 OAuth 2.1 (draft, 2020+)

Consolidates a decade of lessons:

  • Implicit flow removed.
  • Password grant removed.
  • PKCE mandatory for all flows.
  • Exact-match redirect URI (no wildcards).
  • Refresh token rotation recommended.

2. Core Concepts

2.1 Four Roles

  • Resource Owner (RO): the user.
  • Client: the app wanting access.
  • Resource Server (RS): the API.
  • Authorization Server (AS): issues tokens.

RS and AS can be the same org (Google) or separate (Auth0 for AS only).

2.2 Client Types

  • Confidential: can hold a secret (server-side web apps).
  • Public: cannot (mobile apps, SPAs, CLIs) — use PKCE.

OAuth 2.1 effectively requires PKCE for all clients regardless.

2.3 Token Types

  • Access Token: used at the RS. Short-lived (5–60 min).
  • Refresh Token: exchanged for new access tokens. Long-lived. Only sent to AS.
  • ID Token (OIDC): identity assertion. JWT. Consumed by the client.

2.4 Scope

Declares what the token can do: scope=read:email write:calendar profile openid. The openid scope signals OIDC — triggers ID Token issuance. Scopes are AS-defined; only openid, profile, email are standardized.


3. Authorization Code Flow

The canonical flow for web apps.

3.1 Overview

User ←→ Client ←→ Authorization Server ←→ Resource Server

1. User clicks "Log in"
2. Client redirects to AS
3. AS shows login + consent
4. User approves
5. AS redirects back with authorization code
6. Client exchanges code for token (back-channel)
7. Client calls RS with access token

3.2 Steps

Authorization Request:

GET /authorize?
    response_type=code
    &client_id=abc123
    &redirect_uri=https://app.example.com/callback
    &scope=openid profile email
    &state=xyz789
    &code_challenge=<PKCE>
    &code_challenge_method=S256

Authorization Response:

https://app.example.com/callback?
    code=AUTH_CODE_XYZ
    &state=xyz789

Client verifies state matches what it sent (CSRF defense).

Token Request:

POST /token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=AUTH_CODE_XYZ
&redirect_uri=https://app.example.com/callback
&client_id=abc123
&client_secret=secret456
&code_verifier=<PKCE>

Token Response:

{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "def456...",
  "id_token": "eyJhbGciOiJSUzI1NiIs...",
  "scope": "openid profile email"
}

API Call:

GET /api/userinfo
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

3.3 Why This Complexity?

Front-channel/back-channel split: codes travel via the browser; tokens travel only server-to-server, never exposed to the browser. User interaction separation: login/consent happens at the AS — the client never sees the password.


4. PKCE — Required in Modern OAuth

4.1 Problem

Mobile apps and SPAs cannot safely store a client secret. A malicious app can register the same custom URL scheme, intercept the authorization code, and exchange it for a token — the AS has no way to verify which client it's talking to.

4.2 Solution

PKCE (RFC 7636) adds a per-request secret the client proves at token time.

4.3 How It Works

  1. Client generates code_verifier (43–128 random chars).
  2. Computes code_challenge = BASE64URL(SHA256(code_verifier)).
  3. Authorization request includes code_challenge + code_challenge_method=S256.
  4. AS stores the challenge with the issued code.
  5. Token request includes the original code_verifier.
  6. AS verifies SHA256(code_verifier) == stored code_challenge.

Even if an attacker intercepts the code, they cannot produce the verifier held only in the legitimate client's memory.

4.4 Plain vs S256

Never use plain — without hashing, the challenge reveals the verifier. Always S256.

4.5 OAuth 2.1: Mandatory for All Clients

Defense in depth: even if a client secret leaks, PKCE still protects the code exchange.


5. JWT — JSON Web Token

5.1 Structure

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
HeaderPayloadSignature

Three Base64URL-encoded segments.

Header:

{ "alg": "HS256", "typ": "JWT", "kid": "my-key-id" }

Payload (claims):

{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1700000000,
  "exp": 1700003600,
  "iss": "https://example.com",
  "aud": "my-api"
}

Standard claims: sub, iss, aud, exp, iat, nbf, jti.

5.2 Algorithms

  • HS256: HMAC-SHA256. Symmetric (shared secret).
  • RS256: RSA-SHA256. Asymmetric.
  • ES256: ECDSA P-256. Asymmetric, smaller signatures.
  • EdDSA: Ed25519. Fastest, modern.
  • none: never use in production.

OIDC typically uses asymmetric algorithms — AS signs, clients verify with public keys.

5.3 alg: none Vulnerability

Early libraries accepted {"alg":"none"}.{"admin":true}. as valid. Always hardcode the expected algorithm on verification — never trust the alg header.

5.4 Key Confusion

If the server expects RS256 but the attacker switches to HS256, some libraries HMAC-verify using the RSA public key (treated as a shared secret) — forgery succeeds. Fix: lock the algorithm at verification.

5.5 Trade-offs

Pros: self-contained, stateless, standardized. Cons: hard to revoke (valid until exp), payload is signed but not encrypted, size grows with claims.

Practice: short-lived access tokens (5–15 min) + refresh tokens.


6. OIDC — The Authentication Layer

6.1 Why OIDC

OAuth alone gives you an access token but no standardized way to know who logged in. OIDC adds an ID Token consumed directly by the client.

6.2 ID Token vs Access Token

AspectID TokenAccess Token
PurposeUser identityAPI access
AudienceClientResource Server
FormatJWT (required)JWT or opaque
Verified byClientResource Server
Sent in requestsNoYes (Authorization header)

Never send an ID Token to an API. Never use an Access Token as proof of identity.

6.3 ID Token Example

{
  "iss": "https://accounts.google.com",
  "sub": "110169484474386276334",
  "aud": "1234.apps.googleusercontent.com",
  "iat": 1700000000,
  "exp": 1700003600,
  "email": "user@example.com",
  "email_verified": true,
  "name": "John Doe"
}

6.4 Nonce

Client adds nonce=<random> to the authorization request; AS echoes it in the ID Token's nonce claim; client verifies equality. Prevents ID Token replay.

6.5 UserInfo Endpoint

Present the access token, get the user profile:

GET /userinfo
Authorization: Bearer <access_token>

7. OIDC Discovery and JWKS

7.1 Discovery

GET https://accounts.google.com/.well-known/openid-configuration

Returns all endpoints, algorithms, and scopes. Clients need only the issuer URL.

{
  "issuer": "https://accounts.google.com",
  "authorization_endpoint": "...",
  "token_endpoint": "...",
  "userinfo_endpoint": "...",
  "jwks_uri": "...",
  "id_token_signing_alg_values_supported": ["RS256"]
}

7.2 JWKS

{
  "keys": [
    { "kid": "k1", "kty": "RSA", "alg": "RS256", "use": "sig", "n": "...", "e": "AQAB" }
  ]
}

Verifier reads kid from the JWT header, finds the matching key, verifies.

7.3 Key Rotation

AS publishes multiple keys during overlap windows:

Day 1:  [A]
Day 30: [A, B]   # B added, old tokens still valid
Day 60: [B]      # A removed

Clients must refresh JWKS periodically and on unknown kid.


8. Client Credentials Flow

Server-to-server. No user.

POST /token
Authorization: Basic <client_id:client_secret base64>
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&scope=read:users

No ID Token. Store secrets in Vault/Secrets Manager; prefer mTLS; keep tokens short-lived.


9. Device Code Flow

For TVs, consoles, CLIs.

POST /device/code
client_id=abc123
scope=openid profile

Response:

{
  "device_code": "SOME_LONG_CODE",
  "user_code": "WDJB-MJHT",
  "verification_uri": "https://example.com/device",
  "expires_in": 1800,
  "interval": 5
}

Device shows the URL and code; user enters it on their phone; device polls /token. Used by Google TV, GitHub CLI, gcloud auth login.


10. Refresh Token Flow

POST /token
grant_type=refresh_token
&refresh_token=OLD_REFRESH
&client_id=abc123

Rotation (OAuth 2.1): issue a new refresh token every use, invalidate the old one. If a stolen token is used, the legitimate user's next refresh fails — AS detects reuse and invalidates the entire token family.

Storage: HTTPOnly Secure cookies (web), OS keychain (mobile), avoid in SPAs.


11. Web App Security

11.1 Redirect URI Validation

Require exact match against registered URIs. No wildcards (OAuth 2.1).

11.2 State

Random value in the authorization request, checked on callback. Blocks CSRF injection of foreign authorization codes.

11.3 Token Storage in SPAs

LocalStorage is XSS-vulnerable; memory-only loses state on reload; HTTPOnly cookies block JS but need CSRF defense. Best practice: short access tokens + Backend-for-Frontend (tokens stay on the server; browser holds only a session cookie).

11.4 Why Implicit Died

Returned the access token in the URL fragment — ending up in browser history, referrer leaks, accessible to JS, no refresh token. Authorization Code + PKCE supersedes it in all client types.

11.5 Open Redirect

If redirect_uri is not strictly validated, attackers redirect victims to phishing sites after login. Always whitelist.


12. Token Binding: DPoP and mTLS

Bearer tokens are usable by anyone who holds them. Binding ties a token to a specific client.

12.1 mTLS (RFC 8705)

Client uses a TLS client certificate. AS binds the cert thumbprint into the token:

{ "cnf": { "x5t#S256": "SHA256(client_cert)" } }

RS checks the presented TLS cert matches the thumbprint.

12.2 DPoP (RFC 9449)

Client generates a key pair per session. Each request includes a DPoP header — a JWS signed with the private key, containing the public key. AS binds the key thumbprint into the token:

{ "cnf": { "jkt": "SHA256(DPoP public key)" } }

On each API call:

GET /api/users
Authorization: DPoP <access_token>
DPoP: <JWS for this request>

RS verifies the DPoP signature, thumbprint match, and htm/htu bind the proof to method/URL.

12.3 Comparison

AspectBearerDPoP
On token theftAttacker can useCannot (no private key)
Client complexityLowHigh (sign every request)
AdoptionVery highGrowing (fintech, healthcare)

13. FAPI — Financial-grade API

Open banking profile with stricter security.

13.1 FAPI 1.0

  • Baseline (read-only): PKCE + state/nonce mandatory.
  • Advanced (read/write): adds PAR (Pushed Authorization Request), JARM (JWT response), mTLS or DPoP token binding, signed Request Objects.

13.2 PAR

Push the authorization request to the AS via back-channel first:

POST /par
<request object>

Response: { "request_uri": "urn:request:xyz" }

Then:

GET /authorize?client_id=...&request_uri=urn:request:xyz

Shorter URLs, signed parameters, no tampering.

13.3 FAPI 2.0

Simpler and stricter: PAR + DPoP baseline, sender-constrained tokens enforced. Adopted in UK, Australia, Brazil open banking.


14. Providers

  • Auth0: rich features, great docs, pricey, vendor lock-in. B2C/SaaS.
  • Okta: enterprise leader; SSO and governance; costly and complex.
  • Keycloak: open source, self-hosted, operationally heavy.
  • AWS Cognito: integrates with AWS; limited customization.
  • Google / Apple / GitHub: social login, all OIDC.
  • Ory (Hydra/Kratos/Keto): cloud-native open source alternative.

15. Common Security Mistakes

Open redirect — always whitelist redirect_uri.

Authenticating via access token — wrong. Use ID Token or UserInfo.

Refresh token in LocalStorage — XSS-exposed. Use HTTPOnly cookies or BFF.

Over-scoping — request minimum scopes only.

Skipping JWT signature verification — parsing the payload without verifying the signature lets attackers forge anything. Use jsonwebtoken, jose.

Ignoring exp — always enforce expiration.


16. Debugging Tools

  • jwt.io — decode/inspect (never paste production tokens; payload is not secret but contains PII).
  • Postman/Insomnia — OAuth flow automation.
  • oidcdebugger.com — build and inspect authorization requests.
  • MITMproxy / Charles — watch the whole flow.
  • OpenID Conformance Test Suite — official conformance tests.

17. Summary Cheat Sheet

┌─────────────────────────────────────────────────────┐
OAuth 2.0 / OIDC Cheat Sheet├─────────────────────────────────────────────────────┤
Roles: RO, Client, RS, AS│                                                       │
Flows (2.1):1. Authorization Code + PKCE (web/mobile/SPA)2. Client Credentials (server-to-server)3. Device Code (TV/CLI)4. Refresh TokenDeprecated: Implicit, Password│                                                       │
Tokens:Access: API access, short-lived                    │
Refresh: renewal, long-lived, rotate               │
ID: identity (JWT, OIDC only)│                                                       │
JWT: header.payload.signature (Base64URL)│   alg=RS256/ES256/EdDSA (never none)│                                                       │
PKCE: verifier → S256 challenge; S256 only           │
OIDC Discovery: /.well-known/openid-configuration    │
JWKS: kid-based key rotation                         │
│                                                       │
Security: state, nonce, exact redirect_uri, HTTPS,│           short access tokens, PKCE always           │
│                                                       │
Token binding: Bearer (default) / mTLS / DPoPFAPI: PAR, JARM, mTLS or DPoP required               │
└─────────────────────────────────────────────────────┘

18. Quiz

Q1. Difference between OAuth 2.0 and OIDC?

A. OAuth 2.0 is an authorization protocol ("this app may access my data"); OIDC is an authentication protocol ("this user just logged in"). OIDC sits on top of OAuth 2.0 and issues an ID Token (JWT). Using OAuth alone for login is a classic mistake — access tokens don't prove identity.

Q2. What attack does PKCE stop?

A. The authorization code interception attack. On platforms without a secure secret store, a malicious app can register the same URL scheme and intercept the code. PKCE binds the code to a code_verifier held only in the legitimate client's memory — the attacker cannot exchange the intercepted code.

Q3. What is the alg: none vulnerability?

A. Early libraries accepted unsigned JWTs (alg: none), allowing anyone to forge tokens. Defense: hardcode the expected algorithm during verification and never trust the header's alg. A related bug is HS/RS key confusion, where the RSA public key is abused as an HMAC secret.

Q4. What do Discovery and JWKS solve?

A. Configuration automation and key rotation. Discovery exposes all AS endpoints and algorithms in one JSON — clients need only the issuer URL. JWKS exposes signing keys by kid, letting the AS publish overlapping keys during rotation so old and new tokens both validate.

Q5. Why was Implicit flow retired?

A. Access tokens travelled in URL fragments — landing in browser history, referrer leaks, and JS-accessible storage, with no refresh token. Authorization Code + PKCE replaced it across all client types.

Q6. What does DPoP add over bearer tokens?

A. Proof-of-possession binding. The client holds a per-session key pair and signs each request. The AS binds the public-key thumbprint into the token (cnf.jkt); the RS verifies both the JWS and the thumbprint. A stolen token is useless without the private key.

Q7. What is refresh token rotation and why does it matter?

A. Issuing a new refresh token on every use and invalidating the old one. If a stolen token is used first, the legitimate user's next refresh fails and the AS invalidates the entire token family, killing both sessions. OAuth 2.1 strongly recommends this.