OAuth 2.0 and OpenID Connect
"The best security architecture is one where you never have to share the keys to your house -- you just prove you live there."
The Password Anti-Pattern
Consider building an integration between a project management tool and a third-party calendar service. The naive approach is straightforward: ask the user for their calendar username and password, store it, and use it to make API calls on their behalf.
This is the single worst pattern in modern application security. It asks users to hand over their credentials to a third party. Your app now has full access to their account -- not just calendars, but email, contacts, billing, everything. You become a high-value target. One breach of your database, and every user's calendar account is compromised. And you have no way to limit what your app can do -- you have the master key, not a valet key.
The alternative is OAuth 2.0. Instead of the user giving you their password, the user goes to the calendar service directly, authenticates there, and the calendar service gives your app a limited token -- a scoped, revocable, time-limited credential. You never see the user's password. You only get access to what the user explicitly approved. And the token can be revoked at any time without changing the user's password.
What OAuth 2.0 Actually Is (And Is Not)
OAuth 2.0 (RFC 6749) is an authorization framework. It is not an authentication protocol. This distinction matters enormously:
- Authentication answers: "Who are you?" (identity verification)
- Authorization answers: "What are you allowed to do?" (permission granting)
OAuth 2.0 only answers the second question. It provides a mechanism for a user to grant a third-party application limited access to their resources on another service, without sharing their credentials.
OpenID Connect (OIDC), built on top of OAuth 2.0, adds the authentication layer. We will cover both.
The Four Roles
graph TD
subgraph "OAuth 2.0 Roles"
RO["Resource Owner<br/>(The User)<br/>Owns the data,<br/>grants permission"]
Client["Client<br/>(Your Application)<br/>Wants access to<br/>user's resources"]
AS["Authorization Server<br/>(e.g., Google Accounts)<br/>Authenticates user,<br/>issues tokens"]
RS["Resource Server<br/>(e.g., Google Calendar API)<br/>Hosts user's data,<br/>validates tokens"]
end
RO -->|"Grants permission"| AS
AS -->|"Issues access token"| Client
Client -->|"Presents access token"| RS
RS -->|"Returns protected data"| Client
style RO fill:#69db7c,color:#000
style Client fill:#74c0fc,color:#000
style AS fill:#ffa94d,color:#000
style RS fill:#b197fc,color:#000
In practice, the Authorization Server and Resource Server are often operated by the same organization (Google runs both Google Accounts and Google Calendar API), but conceptually they are separate roles.
Authorization Code Flow: The Gold Standard
The Authorization Code flow is the recommended flow for any application with a backend server. It is the most secure OAuth flow because the access token is never exposed to the user's browser.
sequenceDiagram
participant User as User (Browser)
participant App as Client Application<br/>(Your Backend Server)
participant AS as Authorization Server<br/>(e.g., Google)
participant API as Resource Server<br/>(e.g., Google Calendar API)
Note over User,AS: Phase 1: Authorization Request
User->>App: 1. Click "Connect Calendar"
App->>App: 2. Generate random `state` parameter<br/>(CSRF protection, stored in session)
App->>User: 3. Redirect to Authorization Server
User->>AS: 4. GET /authorize?<br/>response_type=code<br/>&client_id=abc123<br/>&redirect_uri=https://app.com/callback<br/>&scope=calendar.read<br/>&state=xyz789
AS->>User: 5. Login page<br/>(if not already authenticated)
User->>AS: 6. Authenticate (username + password + MFA)
AS->>User: 7. Consent screen:<br/>"App wants to read your calendar.<br/>Allow or Deny?"
User->>AS: 8. User clicks "Allow"
Note over User,App: Phase 2: Authorization Code Exchange
AS->>User: 9. Redirect to app's callback URL:<br/>https://app.com/callback?<br/>code=AUTH_CODE_XYZ<br/>&state=xyz789
User->>App: 10. Browser follows redirect<br/>(authorization code in URL)
App->>App: 11. Verify `state` matches session<br/>(prevents CSRF attacks)
App->>AS: 12. POST /token<br/>grant_type=authorization_code<br/>&code=AUTH_CODE_XYZ<br/>&redirect_uri=https://app.com/callback<br/>&client_id=abc123<br/>&client_secret=SECRET_DEF<br/>(server-to-server, not via browser)
AS->>AS: 13. Validate code, client_id,<br/>client_secret, redirect_uri
AS->>App: 14. Token Response:<br/>{access_token: "eyJhbG...",<br/> token_type: "Bearer",<br/> expires_in: 3600,<br/> refresh_token: "dGhpcyBpcyBh...",<br/> scope: "calendar.read"}
Note over App,API: Phase 3: API Access
App->>API: 15. GET /calendar/events<br/>Authorization: Bearer eyJhbG...
API->>API: 16. Validate access token<br/>(signature, expiry, scope)
API->>App: 17. Calendar events data
Note over App,AS: Phase 4: Token Refresh (when access token expires)
App->>AS: 18. POST /token<br/>grant_type=refresh_token<br/>&refresh_token=dGhpcyBpcyBh...<br/>&client_id=abc123<br/>&client_secret=SECRET_DEF
AS->>App: 19. New access token<br/>(+ optionally new refresh token)
Demonstrating Each Step with curl
# Step 1: Build the authorization URL (user opens this in browser)
AUTHORIZE_URL="https://accounts.google.com/o/oauth2/v2/auth?\
response_type=code&\
client_id=YOUR_CLIENT_ID&\
redirect_uri=https%3A%2F%2Fapp.com%2Fcallback&\
scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly&\
state=$(openssl rand -hex 16)&\
access_type=offline"
echo "Open in browser: $AUTHORIZE_URL"
# After user authenticates and consents, browser redirects to:
# https://app.com/callback?code=4/0AX4XfWi...&state=abc123
# Step 2: Exchange authorization code for tokens (server-to-server)
curl -X POST https://oauth2.googleapis.com/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=4/0AX4XfWi..." \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "redirect_uri=https://app.com/callback"
# Response:
# {
# "access_token": "ya29.a0AXooCgsa...",
# "expires_in": 3599,
# "refresh_token": "1//0eYq...",
# "scope": "https://www.googleapis.com/auth/calendar.readonly",
# "token_type": "Bearer"
# }
# Step 3: Use the access token to call the API
curl -H "Authorization: Bearer ya29.a0AXooCgsa..." \
"https://www.googleapis.com/calendar/v3/calendars/primary/events?maxResults=5"
# Step 4: Refresh the access token when it expires
curl -X POST https://oauth2.googleapis.com/token \
-d "grant_type=refresh_token" \
-d "refresh_token=1//0eYq..." \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET"
Why the Authorization Code Flow Is Secure
The key insight is the two-step exchange:
- The authorization code arrives via the browser (front-channel), which is potentially observable
- The code is exchanged for tokens via a direct server-to-server request (back-channel), which includes the client secret
The authorization code is:
- Single-use: Can only be exchanged once. A second attempt invalidates any tokens from the first.
- Short-lived: Typically valid for 1-10 minutes
- Bound: Must be exchanged by the same client_id that requested it, with the same redirect_uri
The access token never touches the browser. It travels only between your server and the API server. This prevents token leakage through browser history, referrer headers, or server access logs.
Why the Implicit Flow Is Deprecated
The Implicit flow (response_type=token) was designed for browser-only single-page applications (SPAs) that had no backend server. The access token was returned directly in the URL fragment:
https://app.com/callback#access_token=eyJhbG...&token_type=Bearer&expires_in=3600
This was problematic for multiple reasons:
graph TD
A["Implicit Flow Problems"] --> B["Token in URL fragment<br/>Visible in browser history<br/>Leaks via Referer header"]
A --> C["No refresh tokens<br/>User must re-authenticate<br/>when token expires"]
A --> D["No client authentication<br/>Anyone with the client_id<br/>can request tokens"]
A --> E["Token injection attacks<br/>Attacker can substitute<br/>their own token"]
A --> F["No confirmation of<br/>token recipient<br/>(no code exchange step)"]
G["Solution: Authorization Code + PKCE"] --> H["Code in URL, token via backchannel"]
G --> I["PKCE prevents code interception"]
G --> J["Works for SPAs without backend"]
G --> K["Supports refresh tokens"]
style A fill:#ff6b6b,color:#fff
style G fill:#69db7c,color:#000
The OAuth 2.0 Security Best Current Practice (BCP, RFC 9700) explicitly recommends against the Implicit flow. The replacement for SPAs is Authorization Code with PKCE.
PKCE: Proof Key for Code Exchange
PKCE (RFC 7636, pronounced "pixie") was originally designed for mobile and native apps that cannot securely store a client secret, but it is now recommended for all OAuth clients, including server-side applications. It prevents authorization code interception attacks.
How PKCE Works
sequenceDiagram
participant App as Client App
participant AS as Authorization Server
Note over App: Before starting the flow:<br/>Generate a random code_verifier<br/>(43-128 chars, URL-safe)
App->>App: code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
App->>App: code_challenge = BASE64URL(SHA256(code_verifier))<br/>= "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
App->>AS: GET /authorize?<br/>response_type=code<br/>&client_id=abc123<br/>&code_challenge=E9Melhoa2Ow...<br/>&code_challenge_method=S256<br/>&redirect_uri=...&scope=...
Note over AS: AS stores the code_challenge<br/>associated with this authorization request
AS->>App: Redirect with authorization code
App->>AS: POST /token<br/>grant_type=authorization_code<br/>&code=AUTH_CODE<br/>&code_verifier=dBjftJeZ4CVP...<br/>&client_id=abc123
AS->>AS: Compute SHA256(code_verifier)<br/>Compare with stored code_challenge<br/>MUST MATCH!
Note over AS: If an attacker intercepted the code,<br/>they cannot exchange it because they<br/>do not have the code_verifier.<br/>The code_challenge was sent in the<br/>initial request and cannot be derived<br/>backward from the challenge.
AS->>App: Token response (if verifier matches)
# Generate PKCE code_verifier and code_challenge
import hashlib
import base64
import secrets
# Step 1: Generate a random code_verifier (43-128 URL-safe characters)
code_verifier = secrets.token_urlsafe(32)
# e.g., "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
# Step 2: Compute the code_challenge (SHA-256 hash, base64url-encoded)
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode('ascii')).digest()
).rstrip(b'=').decode('ascii')
# e.g., "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
# Step 3: Include code_challenge in the authorization request
# Step 4: Include code_verifier in the token exchange
The security property: the code_challenge is a one-way transformation of the code_verifier (SHA-256). An attacker who intercepts the authorization code and the code_challenge cannot derive the code_verifier needed to exchange the code for tokens. Only the original client, which generated the code_verifier, can complete the exchange.
Client Credentials Flow: Machine-to-Machine
The Client Credentials flow is for server-to-server communication where no user is involved. The application authenticates as itself, not on behalf of a user.
sequenceDiagram
participant App as Backend Service<br/>(Cron job, microservice)
participant AS as Authorization Server
participant API as Resource Server / API
App->>AS: POST /token<br/>grant_type=client_credentials<br/>&client_id=service-abc<br/>&client_secret=SECRET_VALUE<br/>&scope=api.internal.read
AS->>AS: Validate client_id and client_secret
AS->>App: {access_token: "eyJ...",<br/>expires_in: 3600,<br/>token_type: "Bearer"}
App->>API: GET /internal/data<br/>Authorization: Bearer eyJ...
API->>App: Data response
# Client credentials flow with curl
curl -X POST https://auth.acme.com/oauth2/token \
-u "service-abc:SECRET_VALUE" \
-d "grant_type=client_credentials" \
-d "scope=api.internal.read"
# Response:
# {
# "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
# "expires_in": 3600,
# "token_type": "Bearer",
# "scope": "api.internal.read"
# }
Use cases: microservice-to-microservice API calls, background job authentication, CI/CD pipeline access to APIs, monitoring systems pulling metrics.
The Client Credentials flow is simpler than the Authorization Code flow because there is no user interaction and no redirect. But the client secret must be protected carefully -- it is equivalent to a service account password.
OpenID Connect: Adding Authentication to OAuth
OAuth 2.0 is an authorization framework -- it tells the Resource Server what the app is allowed to do, but it does not tell the app who the user is. OpenID Connect (OIDC) adds an identity layer on top of OAuth 2.0.
What OIDC Adds
graph TD
subgraph "OAuth 2.0 (Authorization)"
AT["Access Token<br/>What can the app DO?<br/>Scopes: calendar.read,<br/>contacts.read"]
end
subgraph "OpenID Connect (Authentication)"
IDT["ID Token (JWT)<br/>WHO is the user?<br/>Sub: user-123<br/>Email: user@acme.com<br/>Name: Jane Doe<br/>Auth time, nonce, issuer"]
UI["UserInfo Endpoint<br/>/userinfo<br/>Returns additional claims<br/>about the authenticated user"]
DISC["Discovery Document<br/>/.well-known/openid-configuration<br/>Tells clients where all<br/>endpoints are"]
end
OAuth2["OAuth 2.0 Authorization Code Flow"] --> AT
OAuth2 --> IDT
AT --> UI
style IDT fill:#69db7c,color:#000
style AT fill:#74c0fc,color:#000
The ID Token is a JSON Web Token (JWT) that contains identity claims about the user. Unlike an access token (which is opaque to the client and meant for the API), the ID Token is specifically meant to be read and verified by the client application.
ID Token Structure
eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyJ9. <-- Header (base64url)
eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5j <-- Payload (base64url)
b20iLCJzdWIiOiIxMTc0NDUwMzUzMzM4NjUwMTg2MT
ciLCJhdWQiOiJZT1VSX0NMSUVOVF9JRCIsImV4cCI6
MTcxMDI1MDAwMCwiaWF0IjoxNzEwMjQ2NDAwLCJub2
5jZSI6ImFiYzEyMyIsImVtYWlsIjoiYXJqdW5AYWNt
ZS5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibm
FtZSI6IkFyanVuIEt1bWFyIn0.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c <-- Signature (base64url)
Decoded payload:
{
"iss": "https://accounts.google.com", // Issuer: who created this token
"sub": "117445035338650186175", // Subject: unique user ID (stable, never changes)
"aud": "YOUR_CLIENT_ID", // Audience: intended recipient (your app)
"exp": 1710250000, // Expiry: token valid until this Unix timestamp
"iat": 1710246400, // Issued at: when token was created
"nonce": "abc123", // Nonce: replay protection (matches your request)
"email": "user@acme.com", // User's email
"email_verified": true, // Has the provider verified this email?
"name": "Jane Doe", // Display name
"picture": "https://...", // Profile picture URL
"at_hash": "HK6E_P6Dh8Y93mRNtsDB1Q" // Access token hash (binds ID token to access token)
}
ID Token Verification
Your application must verify the ID Token before trusting it:
# Verifying an OIDC ID Token (Python with PyJWT)
import jwt
import requests
def verify_id_token(id_token: str, client_id: str, issuer: str) -> dict:
"""Verify and decode an OIDC ID Token."""
# 1. Fetch the provider's JWKS (JSON Web Key Set)
# The keys used to sign tokens
discovery_url = f"{issuer}/.well-known/openid-configuration"
discovery = requests.get(discovery_url).json()
jwks_uri = discovery["jwks_uri"]
jwks = requests.get(jwks_uri).json()
# 2. Get the public key matching the token's "kid" header
header = jwt.get_unverified_header(id_token)
key = next(k for k in jwks["keys"] if k["kid"] == header["kid"])
# 3. Verify the token signature, expiry, audience, and issuer
claims = jwt.decode(
id_token,
key=jwt.algorithms.RSAAlgorithm.from_jwk(key),
algorithms=["RS256"],
audience=client_id, # Reject if aud doesn't match our client_id
issuer=issuer, # Reject if iss doesn't match expected issuer
options={
"verify_exp": True, # Reject if expired
"verify_iat": True, # Check issued-at time
}
)
# 4. Additional checks
# - Verify nonce matches what you sent in the auth request
# - Check at_hash if present (binds ID token to access token)
return claims
Scopes and Consent
Scopes define the permissions that the application is requesting. They are the mechanism by which OAuth implements the principle of least privilege.
# Common OAuth scopes (Google example):
# openid - Required for OIDC, returns sub claim in ID token
# profile - User's name, picture, locale
# email - User's email address and verified status
# https://www.googleapis.com/auth/calendar.readonly - Read calendar events
# https://www.googleapis.com/auth/calendar.events - Read + write calendar events
# https://www.googleapis.com/auth/drive.readonly - Read Drive files
# https://www.googleapis.com/auth/drive.file - Read/write files created by the app
# Request minimal scopes in the authorization URL:
# scope=openid+email+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly
The consent screen is a critical security control: it is the user's opportunity to understand and approve what the application will be able to do. Well-designed consent screens show:
- Which application is requesting access
- What specific permissions are being requested, in plain language
- That the user can revoke access later
**Consent screen abuse** is a real attack vector. The Nobelium (SolarWinds) threat actor used malicious OAuth applications with broad scope requests to gain persistent access to targets' Microsoft 365 data. The applications were named to look legitimate (e.g., "Mail Security Scanner") and requested permissions like `Mail.Read`, `Mail.ReadWrite`, `MailboxSettings.ReadWrite`. Users approved the consent screens without realizing they were granting a malicious actor full access to their email.
**Defense:** Implement admin consent policies that require IT administrator approval before any application can request sensitive scopes. Azure AD, Google Workspace, and Okta all support this.
OAuth Vulnerabilities and Attacks
OAuth flows involve multiple redirects, URL parameters, and token exchanges. Each step is a potential attack surface. Here are the most critical vulnerabilities, with examples.
1. Redirect URI Manipulation
The redirect_uri tells the authorization server where to send the authorization code. If the authorization server does not strictly validate this parameter, an attacker can redirect the code to their own server.
sequenceDiagram
participant Attacker as Attacker
participant User as Victim (Browser)
participant AS as Authorization Server
participant Evil as Attacker's Server
Attacker->>User: 1. Send phishing link:<br/>https://auth.provider.com/authorize?<br/>client_id=legitimate_app<br/>&redirect_uri=https://evil.com/steal<br/>&response_type=code&scope=email
User->>AS: 2. Browser opens auth page<br/>(looks legitimate!)
AS->>User: 3. Login + Consent screen<br/>(shows "legitimate_app" name)
User->>AS: 4. User authenticates and consents
Note over AS: If redirect_uri validation is<br/>weak (e.g., only checks domain<br/>prefix or allows open redirects)
AS->>User: 5. Redirect with code to evil.com:<br/>https://evil.com/steal?code=AUTH_CODE
User->>Evil: 6. Browser sends code to attacker
Evil->>AS: 7. Exchange code for tokens<br/>(using stolen client_secret or<br/>against a public client)
AS->>Evil: 8. Access token + Refresh token
Evil->>Evil: 9. Attacker has full access<br/>to victim's resources
Defense: The authorization server must perform exact-match validation of redirect_uri against a pre-registered list. No wildcards, no pattern matching, no substring matching. OAuth 2.0 Security BCP requires exact string matching.
2. Authorization Code Injection
An attacker obtains a valid authorization code (through network sniffing, log access, or browser history) and injects it into a legitimate user's callback flow.
Normal flow:
User A authorizes → gets code_A → exchanges for token_A → accesses User A's data
Attack:
Attacker gets code_B (for User B) somehow
Attacker crafts a URL: https://app.com/callback?code=code_B&state=legitimate_state
Attacker tricks User A into clicking this URL
App exchanges code_B → gets User B's tokens → User A sees User B's data
Defense: PKCE prevents this entirely. The code_verifier is bound to the original session, so even if the code is intercepted, it cannot be exchanged by a different client or in a different session. The state parameter provides CSRF protection but does not prevent code injection from the same origin.
3. Token Leakage via Referrer Headers
If an access token is in a URL (as in the deprecated Implicit flow) and the page contains links to external sites, the token leaks via the HTTP Referer header.
1. Authorization server redirects:
https://app.com/callback#access_token=eyJhbG...
2. Page at /callback has a link to https://analytics.example.com
3. When user clicks the link, browser sends:
Referer: https://app.com/callback#access_token=eyJhbG...
4. analytics.example.com now has the access token
Defense: Do not use the Implicit flow. Use Authorization Code + PKCE. If you must handle tokens in the browser, strip fragments before any external navigation and set Referrer-Policy: no-referrer headers.
4. Insufficient Scope Validation
The Resource Server must validate that the access token has the required scopes for the requested operation.
# WRONG: Only checking if the token is valid, not if it has the right scopes
@app.route("/api/calendar/events", methods=["DELETE"])
def delete_event():
token = request.headers.get("Authorization").split(" ")[1]
if validate_token(token): # Only checks signature and expiry
delete_event_from_db(request.args["id"]) # Dangerous!
return {"status": "deleted"}
# RIGHT: Check that the token has the required scope
@app.route("/api/calendar/events", methods=["DELETE"])
def delete_event():
token = request.headers.get("Authorization").split(" ")[1]
claims = validate_token(token)
if "calendar.events.write" not in claims.get("scope", "").split():
return {"error": "insufficient_scope"}, 403
delete_event_from_db(request.args["id"])
return {"status": "deleted"}
5. Refresh Token Theft
Refresh tokens are long-lived and can generate new access tokens. If stolen, they provide persistent access until revoked.
A SaaS company stored OAuth refresh tokens in their database alongside user records. When their database was breached via SQL injection, the attacker obtained refresh tokens for their Google Workspace integration. Using these tokens, the attacker could:
- Read all users' Google Drive files
- Access their Gmail
- View their calendar events
The refresh tokens remained valid even after the company reset all user passwords, because OAuth tokens are independent of the user's password. The tokens had to be individually revoked through the Google Admin console for each affected user -- a process that took days because they had 15,000 users.
**Lessons:**
- Encrypt refresh tokens at rest with a key not stored in the same database
- Implement refresh token rotation: each use of a refresh token returns a new one and invalidates the old one
- Set reasonable expiration times on refresh tokens (days or weeks, not "forever")
- Monitor for abnormal refresh token usage (tokens used from unexpected IPs or at unusual times)
- Have a plan for mass token revocation in case of a breach
Token Types: Access Tokens, Refresh Tokens, ID Tokens
graph TD
subgraph "Access Token"
AT_P["Purpose: Authorize API requests"]
AT_L["Lifetime: Short (minutes to 1 hour)"]
AT_F["Format: JWT or opaque string"]
AT_A["Audience: Resource Server (API)"]
AT_S["Contains: Scopes, subject, expiry"]
end
subgraph "Refresh Token"
RT_P["Purpose: Obtain new access tokens"]
RT_L["Lifetime: Long (days to months)"]
RT_F["Format: Opaque string (random)"]
RT_A["Audience: Authorization Server only"]
RT_S["Sensitive: Equivalent to a session<br/>Must be stored securely"]
end
subgraph "ID Token (OIDC)"
IT_P["Purpose: Prove user identity to the client"]
IT_L["Lifetime: Short (minutes)"]
IT_F["Format: Always JWT (signed, verifiable)"]
IT_A["Audience: Client application"]
IT_S["Contains: User identity claims<br/>(sub, email, name, etc.)"]
end
style AT_P fill:#74c0fc,color:#000
style RT_P fill:#ffa94d,color:#000
style IT_P fill:#69db7c,color:#000
Token Storage Best Practices
| Token | Backend App | SPA (Browser) | Mobile App |
|---|---|---|---|
| Access Token | Server-side session or encrypted DB | Memory only (JS variable) | Secure storage (Keychain/Keystore) |
| Refresh Token | Encrypted DB | HttpOnly secure cookie (BFF pattern) | Secure storage (Keychain/Keystore) |
| ID Token | Server-side session | Memory only | Secure storage |
For SPAs, the Backend-for-Frontend (BFF) pattern is recommended: the SPA communicates with a thin backend that handles OAuth flows and stores tokens in HttpOnly cookies. The SPA never sees the raw tokens.
OpenID Connect Discovery
OIDC providers publish a discovery document at a well-known URL that tells clients where all the endpoints are:
# Fetch the OIDC discovery document
curl -s https://accounts.google.com/.well-known/openid-configuration | python3 -m json.tool
# Key fields in the response:
# {
# "issuer": "https://accounts.google.com",
# "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
# "token_endpoint": "https://oauth2.googleapis.com/token",
# "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
# "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
# "scopes_supported": ["openid", "email", "profile"],
# "response_types_supported": ["code", "id_token", "code id_token"],
# "subject_types_supported": ["public"],
# "id_token_signing_alg_values_supported": ["RS256"],
# "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"]
# }
# Fetch the JWKS (public keys for verifying ID token signatures)
curl -s https://www.googleapis.com/oauth2/v3/certs | python3 -m json.tool
This discovery mechanism means your OIDC client library can auto-configure itself from a single URL -- the issuer URL. Libraries like authlib (Python), passport (Node.js), and Spring Security automatically fetch and cache the discovery document and JWKS.
Putting It All Together: Login with Google
Implement "Login with Google" using OIDC Authorization Code flow with PKCE:
\```bash
# 1. Register your app at console.cloud.google.com
# Get: client_id, client_secret
# Set redirect_uri: http://localhost:8080/callback
# 2. Generate PKCE values
CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-43)
CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr -d '=' | tr '/+' '_-')
# 3. Build authorization URL
echo "Open this URL:"
echo "https://accounts.google.com/o/oauth2/v2/auth?\
response_type=code&\
client_id=YOUR_CLIENT_ID&\
redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback&\
scope=openid+email+profile&\
state=$(openssl rand -hex 16)&\
code_challenge=$CODE_CHALLENGE&\
code_challenge_method=S256&\
nonce=$(openssl rand -hex 16)"
# 4. After authentication, grab the code from the callback URL
# 5. Exchange code for tokens:
curl -X POST https://oauth2.googleapis.com/token \
-d "grant_type=authorization_code" \
-d "code=THE_CODE_FROM_CALLBACK" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "redirect_uri=http://localhost:8080/callback" \
-d "code_verifier=$CODE_VERIFIER"
# 6. Decode the ID token to see user information:
# echo "PAYLOAD_PART" | base64 -d | python3 -m json.tool
\```
Examine the ID token: What claims does it contain? Is the email verified? What is the `sub` value? Is the `aud` your client_id? Is the `nonce` what you sent?
OAuth 2.0 Grant Types Summary
flowchart TD
Start["Which OAuth flow<br/>should I use?"] --> Q1{"Is a user involved?"}
Q1 -->|No| CC["Client Credentials Flow<br/>Machine-to-machine"]
Q1 -->|Yes| Q2{"Does the app have<br/>a backend server?"}
Q2 -->|Yes| AC["Authorization Code Flow<br/>+ PKCE<br/>(ALWAYS add PKCE)"]
Q2 -->|No| Q3{"Is it a mobile/native app<br/>or browser SPA?"}
Q3 -->|Mobile/Native| AC2["Authorization Code Flow<br/>+ PKCE<br/>(no client_secret)"]
Q3 -->|Browser SPA| Q4{"Can you use a BFF<br/>(Backend-for-Frontend)?"}
Q4 -->|Yes| BFF["Authorization Code Flow<br/>via BFF proxy<br/>(recommended)"]
Q4 -->|No| AC3["Authorization Code Flow<br/>+ PKCE in browser<br/>(tokens in memory only)"]
NEVER["NEVER USE:<br/>Implicit Flow<br/>Resource Owner Password<br/> Credentials (ROPC)"]
style CC fill:#74c0fc,color:#000
style AC fill:#69db7c,color:#000
style AC2 fill:#69db7c,color:#000
style BFF fill:#69db7c,color:#000
style AC3 fill:#a9e34b,color:#000
style NEVER fill:#ff6b6b,color:#fff
Security Hardening Checklist
**OAuth 2.0 / OIDC Security Hardening Checklist:**
**Authorization Server Configuration:**
1. Enforce exact-match redirect_uri validation (no patterns, no wildcards)
2. Require PKCE for all clients (public and confidential)
3. Issue short-lived authorization codes (1-10 minutes, single-use)
4. Bind authorization codes to the client_id and redirect_uri
5. Implement refresh token rotation (new refresh token on each use)
6. Set reasonable token lifetimes (access: 1 hour max, refresh: days to weeks)
**Client Application:**
7. Always use the `state` parameter for CSRF protection (random, per-request, verified on callback)
8. Always use PKCE (S256 method, never plain)
9. Verify ID token signature, issuer, audience, expiry, and nonce
10. Request minimal scopes (principle of least privilege)
11. Store tokens securely (never in localStorage for SPAs, use HttpOnly cookies or BFF pattern)
12. Handle token refresh before expiry (proactive, not reactive)
13. Implement proper logout (revoke tokens, clear sessions)
**Resource Server (API):**
14. Validate access tokens on every request (signature, expiry, issuer)
15. Check scopes match the requested operation
16. Validate the `aud` claim matches your API identifier
17. Implement token introspection for opaque tokens (RFC 7662)
18. Return proper OAuth error responses (RFC 6750: invalid_token, insufficient_scope)
**Monitoring:**
19. Log all token issuance, refresh, and revocation events
20. Alert on anomalous patterns (token use from unusual IPs, excessive refresh)
21. Monitor for authorization code reuse attempts (indicates interception)
22. Track scope escalation (applications requesting more scopes over time)
What You've Learned
This chapter covered OAuth 2.0 and OpenID Connect, the protocols that power modern delegated authorization and authentication:
- OAuth 2.0 is authorization, not authentication -- it grants limited, scoped access to resources without sharing passwords; OpenID Connect adds the authentication (identity) layer on top
- The Authorization Code flow is the recommended flow for all applications -- the two-step exchange (code via browser, tokens via backchannel) keeps access tokens away from the browser
- The Implicit flow is deprecated because it exposes tokens in URLs, lacks refresh tokens, and is vulnerable to token injection; Authorization Code + PKCE replaces it for all use cases
- PKCE prevents authorization code interception by binding the code to a cryptographic proof that only the original client possesses; it is now recommended for all OAuth clients, not just mobile apps
- Client Credentials flow handles machine-to-machine communication where no user is involved
- OIDC ID Tokens are signed JWTs containing user identity claims (sub, email, name); they must be verified (signature, issuer, audience, expiry, nonce) before being trusted
- Scopes implement least privilege -- applications should request only the permissions they need, and resource servers must enforce scope validation on every request
- OAuth vulnerabilities include redirect URI manipulation, authorization code injection, token leakage via referrer headers, and refresh token theft -- each has specific defenses (exact-match redirect validation, PKCE, BFF pattern, encrypted storage)
- Token lifecycle management requires secure storage (never localStorage for SPAs), rotation (refresh tokens), expiration (short-lived access tokens), and revocation capabilities
You should never ask users for their passwords to access third-party services. OAuth exists precisely to solve that problem. The user authenticates directly with the service that holds their credentials. Your application receives a scoped, time-limited, revocable token. The user stays in control: they can see what permissions they have granted, and they can revoke access at any time without changing their password. That is the fundamental improvement OAuth brought to the internet -- delegation without trust.
Think of it this way: OAuth is the bouncer checking your wristband at the door of each room. OIDC is the front desk that verified your ID and gave you the wristband in the first place. Together, they give you delegated authorization with verified identity -- which is what almost every modern web and mobile application needs.