OAuth 2.0 and OpenID Connect (OIDC) Explained: From Beginner to Pro

OAuth 2.0 and OpenID Connect (OIDC) Explained: From Beginner to Pro

A comprehensive security masterclass — Authorization Code Flow with PKCE, Access Tokens vs ID Tokens, Refresh Token Rotation, DPoP cryptography, and attack vector mitigations.

In modern web and mobile applications, delegation of access and user identity verification are the fundamental pillars of cybersecurity.

Every time you click "Log in with Google" on a web application, or allow a mobile fitness app to sync data with Apple Health, you are leveraging OAuth 2.0 and OpenID Connect (OIDC). Despite their widespread adoption, security vulnerabilities, misconfigurations, and confusion surrounding grant types remain rife across software teams. In this definitive guide, we break down OAuth 2.0 and OIDC from core security principles to advanced enterprise defense: the hotel valet key mental model, the Authorization Code Flow with PKCE (Proof Key for Code Exchange), JWT Access Tokens vs OIDC ID Tokens, Refresh Token Rotation, DPoP (Demonstrating Proof-of-Possession), and mitigating OAuth CSRF and token theft attacks.


1. The Intuition: The Hotel Valet Key System

1.1 The Valet Key Analogy

Imagine driving a luxury car to a high-end hotel. When you hand your vehicle over to the valet attendant, you do not give them your master physical key—the key that unlocks your glove compartment, opens the trunk containing your luggage, and allows unlimited access to all vehicle settings. Instead, you hand them a limited **Valet Key**.

The valet key allows the attendant to start the engine and drive the car into the parking garage, but it physically locks them out of the trunk and glove box. Furthermore, the hotel issues a temporary paper valet claim ticket valid only for your stay. **OAuth 2.0** is the digital equivalent of that valet key system: it allows third-party applications to perform specific actions on your behalf without learning your master password or gaining unlimited access to your account.

flowchart TD User["Resource Owner (User)"] Client["Client Application (SPA / Mobile App)"] AuthServer["Authorization Server (Identity Provider - e.g. Auth0 / Google)"] ResourceServer["Resource Server (Protected API)"] User -- 1. Grants Permission -- Client Client -- 2. Auth Code Request with PKCE -- AuthServer AuthServer -- 3. Issues Access & ID Tokens -- Client Client -- 4. API Request with Bearer Token -- ResourceServer ResourceServer -- 5. Serves Protected Data -- Client

Diagram: The four fundamental OAuth 2.0 roles interacting to grant delegated API access.

Pitfall — Using OAuth 2.0 for Authentication (The Identity Fallacy): OAuth 2.0 is strictly an Authorization framework (What can this app do?). It tells an API that a token is valid, but it does NOT tell the client application *who* the user is or how they authenticated. For user authentication (Who is the user?), you MUST use **OpenID Connect (OIDC)** built on top of OAuth 2.0.


2. The Roles and Architecture of OAuth 2.0 and OIDC

2.1 The 4 Core OAuth 2.0 Roles

OAuth 2.0 (RFC 6749) decouples the client application from the user's credentials by defining four explicit actors:

  • Resource Owner: The end-user who owns the data and grants access to their account.
  • Client Application: The third-party web or mobile application seeking access to the user's data.
  • Authorization Server: The trusted server that authenticates the user, obtains consent, and issues digital tokens (e.g. Okta, Keycloak, Auth0).
  • Resource Server: The API server hosting the protected user resources, validating access tokens before returning data.

2.2 OpenID Connect (OIDC): The Identity Layer

OpenID Connect (OIDC) extends OAuth 2.0 by introducing a standardized **ID Token** alongside the standard OAuth **Access Token**:

Token Type Primary Consumer Format & Structure Core Purpose
ID Token (OIDC) Client Application JSON Web Token (JWT) Authenticates user identity (`sub`, `email`, `name`)
Access Token (OAuth 2.0) Resource Server (API) Opaque String or Signed JWT Authorizes API actions (`scope`, `aud`, `permissions`)
Refresh Token Authorization Server High-Entropy Opaque String Obtains new Access/ID Tokens without user re-login

2.3 Scopes vs Claims & Fine-Grained Authorization (RBAC / ABAC)

In enterprise API security, software engineers often conflate **Scopes** with **Permissions**:

  • Scopes (`scope: "read:contacts write:orders"`): Define the *maximum set of privileges requested by the Client Application*. They represent what the user allowed the third-party app to do on their behalf. Scopes do NOT define what the user themselves is permitted to do inside the system.
  • Claims (`"roles": ["admin", "auditor"], "tenant_id": "org_992"`): Encoded inside the Access Token JWT payload, claims define the *user's real identity, roles, and enterprise entitlement metadata*.

When a Resource Server evaluates an incoming request, it must check BOTH conditions: $\text{Authorized} = (\text{Scope Matches API Route}) \land (\text{User Claims Satisfy RBAC Policy})$. Evaluating scopes alone leads to privilege escalation vulnerabilities where regular users invoke admin endpoints simply because the client application requested an `admin` scope!


3. The Authorization Code Flow with PKCE (Proof Key for Code Exchange)

3.1 Why the Implicit Flow Is Deprecated

Historically, Single Page Applications (SPAs) used the **Implicit Flow** (RFC 6749), returning access tokens directly in the URL hash fragment (`#access_token=xyz`). This flow is now **BANNED by IETF security best practices** because tokens in URL hash fragments are easily leaked through browser history, server access logs, and malicious browser extensions.

3.2 How PKCE Defeats Code Interception Attacks

To secure public clients (SPAs and native mobile apps that cannot securely hide a `client_secret`), OAuth 2.0 requires the **Authorization Code Flow with PKCE (RFC 7636)**. PKCE dynamically creates a cryptographic secret pair for every single authentication request:

  1. Code Verifier ($S$): A high-entropy random string of 43 to 128 characters drawn from $[a-z, A-Z, 0-9, \text{-}, \text{.}, \text{\_}, \text{\~}]$.
  2. Code Challenge ($C$): The SHA-256 hash of the Code Verifier, Base64URL-encoded:
$$ C = \text{Base64URL}\Big(\text{SHA-256}(S)\Big) $$

During the token exchange, the Client sends the original Code Verifier $S$ to the Authorization Server. The server computes $\text{Base64URL}(\text{SHA-256}(S))$ and verifies it matches the initial Code Challenge $C$. An attacker intercepting the authorization code cannot exchange it for tokens because they lack the secret Code Verifier $S$.

3.3 Cryptographic Entropy of PKCE Verifiers

The cryptographic strength of PKCE relies on the high entropy of the Code Verifier string $S$. With 66 permitted characters $[a-z, A-Z, 0-9, \text{-}, \text{.}, \text{\_}, \text{\~}]$ (providing $\log_2(66) \approx 6.044$ bits of entropy per character), a standard 64-character verifier possesses:

$$ \text{Entropy} = 64 \times \log_2(66) \approx 386.8 \text{ bits of cryptographic entropy} $$

Because the search space contains $66^{64} \approx 10^{116}$ unique permutations, brute-forcing a PKCE code verifier is mathematically impossible under current quantum and classical computing limits.

Pitfall — Using plain text code_challenge_method: PKCE supports `code_challenge_method = S256` and `plain`. Never use `plain` (which sets $C = S$). Always use `S256` to ensure the verifier is cryptographically hashed with SHA-256.


4. Step-by-Step Python PKCE & JWT Implementation

Below is a complete, object-oriented Python module implementing PKCE code verifier/challenge generation and JWT RS256 signature verification:

import base64
import hashlib
import secrets
import string
import json
 
class PKCEGenerator:
    """Generates cryptographically secure PKCE Verifier and Challenge pairs."""
    @staticmethod
    def generate_code_verifier(length: int = 64) -> str:
        if not (43 <= length <= 128):
            raise ValueError("Verifier length must be between 43 and 128 characters.")
        allowed_chars = string.ascii_letters + string.digits + "-._~"
        return ''.join(secrets.choice(allowed_chars) for _ in range(length))
 
    @staticmethod
    def generate_code_challenge(verifier: str) -> str:
        sha256_hash = hashlib.sha256(verifier.encode('utf-8')).digest()
        # Base64URL encoding without padding '='
        return base64.urlsafe_b64encode(sha256_hash).decode('utf-8').rstrip('=')
 
# Example Usage
verifier = PKCEGenerator.generate_code_verifier()
challenge = PKCEGenerator.generate_code_challenge(verifier)
print("Code Verifier (Secret S):", verifier)
print("Code Challenge (Hashed C):", challenge)

4.2 Python RS256 JWT & JWKS Validation Engine

To validate incoming OIDC ID Tokens or OAuth Access Tokens at the Resource Server without hitting the Auth Server database on every request, APIs perform stateless asymmetric signature verification using the Auth Server's public key set (JWKS):

import jwt
import requests
from jwt.exceptions import PyJWTError
 
class JWTSignatureValidator:
    """Validates RS256 signed JWT tokens against remote JWKS (JSON Web Key Set)."""
    def __init__(self, jwks_url: str, expected_issuer: str, expected_audience: str):
        self.jwks_url = jwks_url
        self.issuer = expected_issuer
        self.audience = expected_audience
        self.jwks_client = jwt.PyJWKClient(jwks_url)
 
    def validate_token(self, token: str) -> dict:
        try:
            # Fetch public key matching token's 'kid' header parameter
            signing_key = self.jwks_client.get_signing_key_from_jwt(token)
            
            # Decode and verify RS256 signature, claims, expiration, and audience
            payload = jwt.decode(
                token,
                signing_key.key,
                algorithms=["RS256"],
                audience=self.audience,
                issuer=self.issuer,
                options={"verify_exp": True, "verify_iss": True, "verify_aud": True}
            )
            return payload
        except PyJWTError as e:
            raise PermissionError(f"JWT Verification Failed: {str(e)}")

5. Security Vulnerabilities & Mitigation Strategies

5.1 OAuth CSRF & The State Parameter

In a classic OAuth CSRF attack, an attacker initiates an authorization flow on a client application, intercepts their own valid authorization code before the browser processes it, and tricks a victim into submitting the attacker's authorization code to the victim's authenticated session. This links the victim's client app account to the attacker's resource account, granting the attacker stealth access to the victim's uploaded files or payment records.

To mitigate OAuth CSRF, client applications MUST generate a cryptographically random, unguessable **state token** stored inside an `HttpOnly`, `SameSite=Strict` session cookie. When the Authorization Server redirects back to the client, the client application verifies that the returned `state` query parameter matches the stored session cookie. If the tokens do not match exactly, the login flow is aborted.

5.2 Redirect URI Poisoning & Open Redirectors

Attacker-controlled redirect URIs represent one of the most critical vulnerabilities in OAuth 2.0 implementations. If an Authorization Server allows wildcard pattern matching for redirect URIs (e.g. `https://example.com/*`), an attacker can craft an authorization link pointing to an open redirector or directory traversal endpoint on your domain (e.g. `https://example.com/oauth/callback/../../attacker`):

When the user authenticates, the Authorization Server sends the authorization code to the attacker's server. To eliminate this attack vector, modern OAuth 2.0 specifications mandate **Exact String Matching** for all registered `redirect_uri` values. Wildcards, sub-domain matching, and path traversal parameters MUST be rejected by the Authorization Server.

5.3 Refresh Token Rotation & Theft Detection

Because public clients store refresh tokens in user environments, stolen refresh tokens present long-term security risks. **Refresh Token Rotation** solves this by issuing a single-use refresh token. When a client uses Refresh Token $RT_1$, the server invalidates $RT_1$ and issues $RT_2$.

Pitfall — Automatic Token Family Invalidation: If an attacker steals $RT_1$ and attempts to use it after the legitimate client has already exchanged $RT_1$ for $RT_2$, the server detects a reuse violation of $RT_1$. The server MUST immediately revoke the entire token family ($RT_1, RT_2$, and all active access tokens), forcing the real user to re-authenticate.


6. Advanced: Demonstration of Proof-of-Possession (DPoP)

6.1 Defeating Bearer Token Theft with DPoP (RFC 9449)

Standard OAuth 2.0 access tokens are **Bearer Tokens**—anyone who intercepts the token string can use it to access protected APIs. **DPoP (Demonstrating Proof-of-Possession)** cryptographic binding binds the access token to the client's asymmetric private key.

On every API request, the client generates a short-lived, signed **DPoP Proof JWT** containing the HTTP method, request URI, and public key (`jwk`). The Resource Server verifies that the access token hash (`ath`) matches the presented public key, rendering stolen bearer tokens useless on other machines.

6.2 DPoP Header Structure & Verification Protocol

A valid DPoP HTTP request includes two headers: `Authorization: DPoP ` and `DPoP: `. The DPoP Proof JWT payload is structured as follows:

{
  "typ": "dpop+jwt",
  "alg": "ES256",
  "jwk": {
    "kty": "EC",
    "crv": "P-256",
    "x": "f83OJ3D2xD...",
    "y": "x_daeWGe..."
  }
}
.
{
  "jti": "-B9jh2SFODA", # Unique single-use JWT ID preventing replay
  "htm": "POST", # HTTP Method must match API request
  "htu": "https://api.site/data", # Target URI must match API request exactly
  "iat": 1719842400, # Issued-at timestamp (valid for <= 60 seconds)
  "ath": "fU85B28y..." # Base64URL(SHA-256(Access_Token))
}

7. Advanced: Back-Channel Logout & Revocation Standards

7.1 OpenID Connect Back-Channel Logout (RFC 8693)

When a user logs out of an Identity Provider (e.g. Okta), all associated client sessions must be terminated. **Back-Channel Logout** sends direct, server-to-server HTTPS POST requests containing a signed Logout Token (`logout_token`) to registered client endpoints, purging local user sessions asynchronously without relying on browser redirects.

7.2 OAuth 2.0 Token Revocation (RFC 7009) & Introspection (RFC 7662)

When a user clicks "Log Out" or revokes an application's permissions, the client notifies the Authorization Server via the **Token Revocation Endpoint** (`POST /oauth/revoke`). The server invalidates the specified token and its downstream refresh chains.

For opaque access tokens (non-JWT strings), microservice Resource Servers query the **Token Introspection Endpoint** (`POST /oauth/introspect`) to query real-time active status, scope permissions, and user ID directly from the Authorization Server.


8. OAuth 2.0 Grant Types Comparison Matrix

The table below compares standard OAuth 2.0 grant types and their current security status:

Grant Type Client Type IETF Security Status Primary Engineering Use Case
Auth Code + PKCE Public Clients (SPAs, Mobile) & Confidential RECOMMENDED (Gold Standard) All modern web, mobile, and desktop applications
Client Credentials Confidential Server-to-Server RECOMMENDED Machine-to-machine microservice communication
Implicit Grant Browser SPAs DEPRECATED & BANNED Do not use (tokens exposed in URL fragment)
Resource Owner Password Legacy Native Apps DEPRECATED & BANNED Do not use (app collects raw user passwords)

9. Interactive: OAuth 2.0 PKCE Handshake Simulator

Click "Step PKCE Authentication" to trace a complete PKCE authorization code exchange:

Simulation Idle. Click button to start PKCE flow...
1. Client Generates Secret Verifier (S) & Hashed Challenge (C)
Idle
2. Redirect to Auth Server with Challenge C -> Return Auth Code
Idle
3. Exchange Auth Code + Secret Verifier S -> Verify Hash == C
Idle
4. Tokens Issued (Access Token + OIDC ID Token)
Idle

10. Authentication Handshake Latency Comparison

The chart below compares latency (in milliseconds) across token validation architectures:


11. Frequently Asked Questions

Q1: What is the fundamental difference between OAuth 2.0 and OpenID Connect (OIDC)?

OAuth 2.0 is an authorization framework providing access tokens to APIs. OIDC is an identity layer built on OAuth 2.0 providing ID tokens (JWTs) to authenticate user identity to client applications.

Q2: Why is PKCE required for Single Page Applications (SPAs)?

SPAs are public clients that cannot securely hide a static `client_secret`. PKCE dynamically generates a one-time cryptographic verifier/challenge pair, preventing authorization code interception attacks.

Q3: Where should Access Tokens be stored in a web browser?

Store tokens in `HttpOnly`, `Secure`, `SameSite=Strict` cookies to defeat XSS attacks. Avoid storing sensitive tokens in `localStorage` or `sessionStorage` where malicious scripts can access them.

Q4: What is Refresh Token Rotation?

A security practice where the server issues a new single-use refresh token every time a refresh token is used. If a previously used refresh token is presented again, the server revokes the entire token family.

Q5: What is DPoP (Demonstrating Proof-of-Possession)?

DPoP cryptographic binding binds access tokens to a client's asymmetric private key, ensuring stolen bearer tokens cannot be replayed by attackers from different devices.

Q6: Why is the Implicit Grant flow deprecated by IETF?

Implicit grant returns access tokens in URL hash fragments, exposing them to browser history, server logs, and malicious referrer headers.

Q7: What is the purpose of the state parameter in OAuth 2.0?

The `state` parameter contains an un-guessable session token that prevents Cross-Site Request Forgery (CSRF) attacks during the authorization redirect step.

Q8: How does RS256 differ from HS256 for JWT signing?

HS256 uses a shared symmetric secret requiring both Auth Server and API to share the key. RS256 uses asymmetric RSA keys, allowing APIs to verify signatures using a public key without holding the private signing key.

Q9: What is OAuth 2.1 and how does it change the OAuth 2.0 specification?

OAuth 2.1 is an updated specification consolidating OAuth 2.0 security best practices. It formally deprecates the Implicit and Resource Owner Password Credentials grants, mandates PKCE for all authorization code flows, enforces exact string matching for redirect URIs, and restricts bearer token usage.

Q10: How do you handle JWT key rotation without causing API downtime?

Authorization Servers publish multiple active public keys in their JSON Web Key Set (JWKS) endpoint (`/.well-known/jwks.json`). Each JWT includes a `kid` (Key ID) header parameter. APIs dynamically fetch and cache public keys by `kid`, allowing seamless key rotation without restarting microservices.

Q11: What is the nonce parameter in OpenID Connect?

The `nonce` parameter is a cryptographically random string generated by the client application, passed in the authorization request, and echoed back inside the ID Token payload (`nonce` claim) to defeat ID Token replay attacks.

Q12: How does OAuth Client Credentials Grant differ from Authorization Code Flow?

Client Credentials Grant is used exclusively for server-to-server (machine-to-machine) communications where no human user is present. Authorization Code Flow requires interactive user authentication and explicit consent prompts.

Post a Comment

Previous Post Next Post