History of Web Authentication
The history of web authentication is a story of evolving threat models and increasing complexity. In the early days of the World Wide Web (1993-1995), HTTP was stateless and anonymous. The first authentication mechanism was HTTP Basic Authentication (RFC 1945, 1996), which sent the username and password as a Base64-encoded string in every request. It was simple, but deeply insecure — credentials were transmitted in nearly plain text on every request, and there was no concept of sessions, logout, or token expiry.
Cookies (introduced by Netscape in 1994 and standardized in RFC 2109 in 1997) enabled stateful sessions. Servers could set a Set-Cookie header, and the browser would include the cookie in every subsequent request. Combined with server-side session stores, this became the dominant authentication pattern for the next two decades — and it still is for many applications.
The rise of third-party integrations in the mid-2000s exposed a critical problem: if you wanted a third-party app to access your data on another service (e.g., a photo printing service accessing your Flickr photos), you had to give it your password. This was the "password anti-pattern" — it was insecure, irrevocable, and gave the third party full access to your account.
OAuth 1.0 (2007) solved this by introducing delegated authorization — a user could authorize a third-party app to access specific resources without sharing their password. However, OAuth 1.0 required cryptographic signatures on every request, making it complex to implement. In 2010, Twitter's implementation of OAuth 1.0 was one of the first widely-used deployments, but developers struggled with the signing requirements.
OAuth 2.0 (RFC 6749, October 2012) was a complete rewrite. It dropped the cryptographic signatures in favor of bearer tokens over TLS, introduced multiple "grant types" for different use cases (web apps, mobile apps, server-to-server), and separated the roles of authorization server and resource server. Today, OAuth 2.0 is the foundation of authentication at Google, Facebook, Microsoft, GitHub, and virtually every major web platform.
JSON Web Tokens (JWT, RFC 7519, May 2015) provided a standardized, self-contained token format that could carry claims (user identity, permissions, expiry) without requiring the server to look up a session store. JWTs became the default token format for OAuth 2.0 access tokens and OpenID Connect ID tokens.
| Year | Technology | Key Contribution |
|---|---|---|
| 1996 | HTTP Basic Auth (RFC 1945) | First standardized web authentication |
| 1997 | HTTP Cookies (RFC 2109) | Stateful sessions in a stateless protocol |
| 1999 | HTTP Digest Auth (RFC 2617) | Challenge-response without plaintext passwords |
| 2005 | SAML 2.0 (OASIS) | Enterprise SSO with XML assertions |
| 2007 | OAuth 1.0 | Delegated authorization with crypto signatures |
| 2012 | OAuth 2.0 (RFC 6749) | Simplified delegation with bearer tokens over TLS |
| 2014 | OpenID Connect 1.0 | Identity layer on OAuth 2.0 |
| 2015 | JWT (RFC 7519) | Self-contained, signed token format |
| 2019 | OAuth 2.1 (draft) | Consolidates best practices, mandates PKCE |
OAuth 2.0 Overview
OAuth 2.0 defines four roles and multiple grant types (flows) that determine how tokens are obtained. Understanding these roles and flows is the foundation for implementing any OAuth-based authentication system.
The Four Roles
| Role | Description | Example |
|---|---|---|
| Resource Owner | The user who owns the data and grants access | You (the person clicking "Allow") |
| Client | The application requesting access on the user's behalf | A mobile app, SPA, or backend service |
| Authorization Server | Issues tokens after authenticating the user and obtaining consent | Google Accounts, Auth0, Keycloak |
| Resource Server | Hosts the protected resources, validates tokens | Google Drive API, GitHub API |
Grant Types
| Grant Type | Use Case | Status in OAuth 2.1 |
|---|---|---|
| Authorization Code | Server-side web apps | Recommended (with PKCE) |
| Authorization Code + PKCE | SPAs, mobile apps, all public clients | Mandatory for public clients |
| Client Credentials | Machine-to-machine (no user) | Retained |
| Device Code | Smart TVs, CLI tools (limited input) | Retained |
| Implicit | SPAs (legacy) | Removed — use Auth Code + PKCE |
| Resource Owner Password | Trusted first-party apps (legacy) | Removed — use Auth Code |
Authorization Code Flow
The Authorization Code flow is the most secure OAuth 2.0 flow for user-facing applications. It uses a back-channel (server-to-server) exchange that keeps tokens away from the browser.
Step-by-Step Flow
| Step | From | To | Action |
|---|---|---|---|
| 1 | User | Client | User clicks "Login with Google" |
| 2 | Client | Auth Server | Redirect to /authorize with client_id, redirect_uri, scope, state |
| 3 | Auth Server | User | Shows consent screen ("App X wants to access your email") |
| 4 | User | Auth Server | User approves the request |
| 5 | Auth Server | Client | Redirects to redirect_uri with code and state |
| 6 | Client (backend) | Auth Server | POST to /token with code, client_id, client_secret |
| 7 | Auth Server | Client (backend) | Returns access_token, refresh_token, id_token |
| 8 | Client (backend) | Resource Server | API call with Authorization: Bearer {access_token} |
// Step 2: Construct the authorization URL
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
authUrl.searchParams.set('client_id', 'YOUR_CLIENT_ID');
authUrl.searchParams.set('redirect_uri', 'https://app.example.com/callback');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'openid email profile');
authUrl.searchParams.set('state', generateRandomState()); // CSRF protection
authUrl.searchParams.set('access_type', 'offline'); // Request refresh token
// Redirect the user
window.location.href = authUrl.toString();
# Step 6: Exchange the authorization code for tokens (server-side)
import requests
token_response = requests.post('https://oauth2.googleapis.com/token', data={
'grant_type': 'authorization_code',
'code': request.args.get('code'),
'redirect_uri': 'https://app.example.com/callback',
'client_id': 'YOUR_CLIENT_ID',
'client_secret': 'YOUR_CLIENT_SECRET',
})
tokens = token_response.json()
access_token = tokens['access_token']
refresh_token = tokens.get('refresh_token')
id_token = tokens.get('id_token')
# Step 8: Use the access token to call Google APIs
user_info = requests.get('https://www.googleapis.com/oauth2/v3/userinfo',
headers={'Authorization': f'Bearer {access_token}'}
).json()
print(f"Logged in as: {user_info['email']}")
state parameter is not optional — it is your primary defense against CSRF attacks. Generate a cryptographically random string, store it in the user's session, and verify it matches when the callback is received. If the state does not match, reject the request immediately.
PKCE for Public Clients
Proof Key for Code Exchange (PKCE, pronounced "pixy", RFC 7636) was designed to protect the authorization code flow for public clients — applications that cannot securely store a client secret, such as single-page applications (SPAs), mobile apps, and CLI tools.
Why PKCE Exists
Without PKCE, a malicious application on the same device could intercept the authorization code during the redirect (through custom URL scheme hijacking on mobile, or browser history on desktop) and exchange it for tokens. Since public clients have no client_secret, the authorization server cannot verify that the code is being redeemed by the legitimate client. PKCE solves this by binding the authorization request to the token exchange with a cryptographic proof.
How PKCE Works
// Step 1: Generate a code_verifier (cryptographically random, 43-128 chars)
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64URLEncode(array);
}
// Step 2: Derive the code_challenge from the verifier
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64URLEncode(new Uint8Array(digest));
}
function base64URLEncode(buffer) {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
// Step 3: Include in authorization request
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Store codeVerifier in sessionStorage (needed for token exchange)
sessionStorage.setItem('pkce_code_verifier', codeVerifier);
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('client_id', 'SPA_CLIENT_ID');
authUrl.searchParams.set('redirect_uri', 'https://app.example.com/callback');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('state', generateRandomState());
window.location.href = authUrl.toString();
// Step 4: Exchange code for tokens — now with code_verifier instead of client_secret
async function exchangeCodeForTokens(authCode) {
const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
redirect_uri: 'https://app.example.com/callback',
client_id: 'SPA_CLIENT_ID',
code_verifier: codeVerifier, // Proof that we initiated the request
}),
});
const tokens = await response.json();
// Clean up
sessionStorage.removeItem('pkce_code_verifier');
return tokens;
}
JWT Token Structure
A JSON Web Token (JWT) is a compact, URL-safe means of representing claims between two parties. It consists of three Base64URL-encoded parts separated by dots: header.payload.signature.
// A complete JWT (line breaks for readability — actual JWTs are one line)
// eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImFiYzEyMyJ9
// .eyJzdWIiOiJ1c2VyXzEyMzQ1Iiwi...
// .SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
// Header (decoded):
{
"alg": "RS256", // Signing algorithm
"typ": "JWT", // Token type
"kid": "abc123" // Key ID (for JWKS lookup)
}
// Payload (decoded):
{
"iss": "https://auth.example.com", // Issuer
"sub": "user_12345", // Subject (user ID)
"aud": "https://api.example.com", // Audience
"exp": 1775000000, // Expiration (Unix timestamp)
"iat": 1774996400, // Issued at
"nbf": 1774996400, // Not before
"jti": "unique-token-id-abc", // JWT ID (unique identifier)
"scope": "read:users write:posts", // Scopes / permissions
"email": "user@example.com", // Custom claim
"name": "Jane Doe", // Custom claim
"roles": ["admin", "editor"] // Custom claim
}
Standard Claims (Registered)
| Claim | Full Name | Description |
|---|---|---|
iss | Issuer | Who issued the token (auth server URL) |
sub | Subject | Who the token is about (user ID) |
aud | Audience | Who the token is intended for (API URL) |
exp | Expiration | When the token expires (Unix timestamp) |
iat | Issued At | When the token was issued |
nbf | Not Before | Token is not valid before this time |
jti | JWT ID | Unique token identifier (for revocation) |
Signing Algorithms
| Algorithm | Type | Use Case |
|---|---|---|
HS256 | Symmetric (HMAC-SHA256) | Same key for signing and verification — simple but key must be shared |
RS256 | Asymmetric (RSA-SHA256) | Private key signs, public key verifies — ideal for distributed systems |
ES256 | Asymmetric (ECDSA-SHA256) | Smaller keys, same security as RS256 — recommended for new systems |
none | No signature | NEVER use in production — has caused major vulnerabilities |
"alg": "none" attack is one of the most common JWT vulnerabilities. If your validation library accepts the none algorithm, an attacker can forge tokens by removing the signature entirely. Always explicitly specify which algorithms your server accepts and reject anything else.
JWT Validation & Security
Validating a JWT is not just about checking the signature. A comprehensive validation process must verify multiple claims to ensure the token is authentic, unexpired, and intended for your API.
// Node.js: Complete JWT validation with jose library
import * as jose from 'jose';
async function validateToken(token) {
// Fetch the JWKS (JSON Web Key Set) from the authorization server
const JWKS = jose.createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json')
);
try {
const { payload, protectedHeader } = await jose.jwtVerify(token, JWKS, {
// Required validations
issuer: 'https://auth.example.com', // Must match iss claim
audience: 'https://api.example.com', // Must match aud claim
algorithms: ['RS256', 'ES256'], // Whitelist algorithms
clockTolerance: 30, // 30-second clock skew
maxTokenAge: '1h', // Reject tokens older than 1h
});
console.log('Token is valid!');
console.log('User:', payload.sub);
console.log('Scopes:', payload.scope);
return payload;
} catch (error) {
if (error.code === 'ERR_JWT_EXPIRED') {
console.error('Token expired');
} else if (error.code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
console.error('Invalid signature');
} else {
console.error('Token validation failed:', error.message);
}
throw error;
}
}
# Python: JWT validation with PyJWT
import jwt
import requests
# Fetch JWKS from the authorization server
jwks_url = 'https://auth.example.com/.well-known/jwks.json'
jwks = requests.get(jwks_url).json()
# Get the signing key from JWKS
from jwt.algorithms import RSAAlgorithm
public_key = RSAAlgorithm.from_jwk(jwks['keys'][0])
def validate_token(token):
try:
payload = jwt.decode(
token,
public_key,
algorithms=['RS256'], # Whitelist algorithms
audience='https://api.example.com',
issuer='https://auth.example.com',
options={
'require': ['exp', 'iss', 'sub', 'aud'], # Required claims
'verify_exp': True,
'verify_iss': True,
'verify_aud': True,
},
leeway=30, # 30-second clock skew tolerance
)
return payload
except jwt.ExpiredSignatureError:
raise Exception('Token expired')
except jwt.InvalidAudienceError:
raise Exception('Invalid audience')
except jwt.InvalidIssuerError:
raise Exception('Invalid issuer')
except jwt.InvalidSignatureError:
raise Exception('Invalid signature — possible token tampering')
JWKS — JSON Web Key Set
When using asymmetric algorithms (RS256, ES256), the authorization server publishes its public keys at a well-known endpoint (typically /.well-known/jwks.json). Resource servers fetch these keys to verify token signatures without needing a shared secret.
// Example JWKS response from /.well-known/jwks.json
{
"keys": [
{
"kty": "RSA",
"kid": "abc123",
"use": "sig",
"alg": "RS256",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiA...",
"e": "AQAB"
},
{
"kty": "RSA",
"kid": "def456",
"use": "sig",
"alg": "RS256",
"n": "pjdss8ZaDfEH6K6U7GeW2nxDqR4IP049ftalC...",
"e": "AQAB"
}
]
}
kid header does not match any cached key. This enables seamless key rotation — the authorization server can add new keys before retiring old ones.
Refresh Token Strategy
Access tokens are intentionally short-lived (typically 5-60 minutes) to limit the damage if they are stolen. Refresh tokens enable clients to obtain new access tokens without requiring the user to re-authenticate. However, refresh tokens are high-value targets and require careful handling.
Refresh Token Rotation
Refresh token rotation is a security practice where the authorization server issues a new refresh token every time the old one is used. The old token is immediately invalidated. If an attacker steals and uses a refresh token, the legitimate user's next attempt to use the (now-invalid) old token triggers revocation of the entire token family, alerting the system to the breach.
// Implementing token refresh with rotation
async function refreshAccessToken() {
const refreshToken = getStoredRefreshToken(); // from httpOnly cookie
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: 'YOUR_CLIENT_ID',
}),
credentials: 'include', // Send cookies
});
if (response.status === 401) {
// Refresh token was revoked — force re-login
redirectToLogin();
return null;
}
const tokens = await response.json();
// Store the NEW refresh token (old one is now invalid)
storeRefreshToken(tokens.refresh_token);
return tokens.access_token;
}
// Automatic retry with token refresh (interceptor pattern)
async function apiCall(url, options = {}) {
let accessToken = getStoredAccessToken();
let response = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${accessToken}` },
});
if (response.status === 401) {
// Token expired — refresh and retry
accessToken = await refreshAccessToken();
if (!accessToken) return null;
response = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${accessToken}` },
});
}
return response;
}
Token Storage Recommendations
| Storage Method | XSS Safe | CSRF Safe | Recommendation |
|---|---|---|---|
| localStorage | No | Yes | Never store refresh tokens here |
| sessionStorage | No | Yes | Acceptable for short-lived access tokens |
| httpOnly cookie | Yes | No (need CSRF token) | Best for refresh tokens |
| In-memory (JS variable) | Yes | Yes | Best for access tokens (lost on refresh) |
| httpOnly + SameSite=Strict | Yes | Yes | Best overall for both token types |
Sliding Window vs Fixed Expiry
Two additional refresh token strategies deserve attention beyond simple rotation:
Sliding Window: The refresh token's absolute expiry remains fixed (e.g., 30 days from first issue), but each use extends the idle timeout (e.g., 7 days of inactivity). This means an active user stays logged in for up to 30 days, but an inactive user is logged out after 7 days of not using the app. This is the approach used by Auth0 and many banking applications.
// Sliding window refresh token logic (server-side)
async function refreshToken(oldToken) {
const tokenRecord = await db.refreshTokens.findByToken(oldToken);
if (!tokenRecord || tokenRecord.revoked) {
// Token reuse detected — revoke entire family
if (tokenRecord) {
await db.refreshTokens.revokeFamily(tokenRecord.familyId);
}
throw new Error('Invalid refresh token');
}
const now = Date.now();
const absoluteExpiry = tokenRecord.familyCreatedAt + (30 * 24 * 60 * 60 * 1000); // 30 days
const idleExpiry = tokenRecord.lastUsedAt + (7 * 24 * 60 * 60 * 1000); // 7 days idle
if (now > absoluteExpiry || now > idleExpiry) {
await db.refreshTokens.revokeFamily(tokenRecord.familyId);
throw new Error('Refresh token expired');
}
// Rotate: invalidate old, issue new
await db.refreshTokens.revoke(tokenRecord.id);
const newToken = await db.refreshTokens.create({
userId: tokenRecord.userId,
familyId: tokenRecord.familyId,
familyCreatedAt: tokenRecord.familyCreatedAt, // preserve original
lastUsedAt: now,
});
return {
access_token: generateAccessToken(tokenRecord.userId),
refresh_token: newToken.token,
};
}
Fixed Expiry: The simplest approach — the refresh token has a fixed lifetime (e.g., 30 days) and cannot be extended. When it expires, the user must re-authenticate. This is common in high-security applications where periodic re-authentication is a compliance requirement.
OpenID Connect
OpenID Connect (OIDC) is an identity layer built on top of OAuth 2.0. While OAuth 2.0 only provides authorization (access to resources), OIDC adds authentication — it tells the client who the user is. It does this through an ID token — a JWT that contains claims about the user's identity.
OIDC Scopes
// OpenID Connect uses specific scopes to request identity information
const scopes = [
'openid', // Required — signals this is an OIDC request
'profile', // name, family_name, given_name, picture, etc.
'email', // email, email_verified
'address', // formatted address, street, city, etc.
'phone', // phone_number, phone_number_verified
];
// The authorization URL with OIDC scopes
const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth'
+ '?client_id=YOUR_CLIENT_ID'
+ '&redirect_uri=https://app.example.com/callback'
+ '&response_type=code'
+ '&scope=openid%20email%20profile'
+ '&state=' + generateState()
+ '&nonce=' + generateNonce(); // OIDC-specific: prevents replay attacks
Discovery Document
// Every OIDC provider publishes a discovery document at
// /.well-known/openid-configuration
// Example: https://accounts.google.com/.well-known/openid-configuration
{
"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", "token", "id_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"claims_supported": ["sub", "email", "email_verified", "name", "picture"]
}
Session Management
Session management determines how your application tracks whether a user is authenticated. The two dominant approaches — server-side sessions and token-based sessions — have different tradeoffs for scalability, security, and complexity.
Server-Side Sessions vs Token-Based
| Aspect | Server-Side Sessions | Token-Based (JWT) |
|---|---|---|
| State | Server stores session data (Redis, DB) | Client holds self-contained token |
| Scalability | Requires shared session store | Stateless — any server can validate |
| Revocation | Easy — delete from store | Hard — token is valid until expiry |
| Storage size | Small cookie (session ID only) | Larger (JWT contains all claims) |
| Logout | Immediate — session destroyed | Not immediate — token remains valid |
| Mobile-friendly | Less (cookie management) | More (tokens in headers) |
# Python/Flask: Server-side session with Redis
from flask import Flask, session
from flask_session import Session
import redis
app = Flask(__name__)
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_REDIS'] = redis.Redis(host='localhost', port=6379)
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SECURE'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour
Session(app)
@app.route('/login', methods=['POST'])
def login():
user = authenticate(request.form['username'], request.form['password'])
if user:
session['user_id'] = user.id
session['email'] = user.email
session.permanent = True
return redirect('/dashboard')
return 'Invalid credentials', 401
@app.route('/logout')
def logout():
session.clear() # Immediate revocation
return redirect('/')
Single Sign-On (SSO)
SSO allows users to authenticate once and access multiple applications without re-entering credentials. In an OIDC-based SSO setup, the authorization server maintains a session cookie. When a second application redirects to the same authorization server, the server recognizes the existing session and issues tokens immediately (without showing a login screen).
Session Fixation Prevention
# Always regenerate the session ID after authentication
# This prevents session fixation attacks where an attacker
# sets a known session ID before the user logs in
from flask import session
import uuid
@app.route('/login', methods=['POST'])
def login():
user = authenticate(request.form['username'], request.form['password'])
if user:
# Regenerate session ID to prevent fixation
old_data = dict(session)
session.clear()
session.sid = str(uuid.uuid4()) # New session ID
session.update(old_data)
session['user_id'] = user.id
session['authenticated'] = True
return redirect('/dashboard')
return 'Invalid credentials', 401
Logout Strategies
Logout is surprisingly complex in token-based systems. Unlike server-side sessions where destroying the session immediately invalidates the user, JWTs remain valid until they expire. Here are three strategies for handling logout:
// Strategy 1: Short access tokens + delete refresh token
// Most common and simplest approach
async function logout(req, res) {
// Delete the refresh token from the database
const refreshToken = req.cookies.refresh_token;
if (refreshToken) {
await db.refreshTokens.revoke(refreshToken);
}
// Clear cookies
res.clearCookie('refresh_token', {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
// The access token will expire in 5-15 minutes on its own
// For immediate invalidation, see Strategy 2
res.json({ success: true });
}
// Strategy 2: Token blocklist (for immediate revocation)
// Store revoked token JTI in Redis with TTL matching token expiry
async function logoutImmediate(req, res) {
const token = req.headers.authorization?.replace('Bearer ', '');
const payload = decodeJWT(token); // decode without verifying
// Add to blocklist with TTL = remaining lifetime
const ttl = payload.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await redis.setex(`blocklist:${payload.jti}`, ttl, '1');
}
// Also revoke refresh token
await db.refreshTokens.revokeByUserId(payload.sub);
res.clearCookie('refresh_token');
res.json({ success: true });
}
// In your auth middleware, check the blocklist
async function verifyToken(token) {
const payload = jwt.verify(token, publicKey);
const isBlocked = await redis.exists(`blocklist:${payload.jti}`);
if (isBlocked) throw new Error('Token has been revoked');
return payload;
}
# Strategy 3: OIDC Back-Channel Logout
# For SSO environments where multiple apps share the same identity provider
# The IdP sends a logout token to each relying party's backchannel endpoint
from flask import Flask, request
import jwt
app = Flask(__name__)
@app.route('/backchannel-logout', methods=['POST'])
def backchannel_logout():
"""Receives logout notification from the IdP."""
logout_token = request.form.get('logout_token')
# Verify the logout token (signed by the IdP)
payload = jwt.decode(logout_token, idp_public_key, algorithms=['RS256'],
audience='https://app.example.com')
# The logout token contains the user's 'sub' claim
user_id = payload['sub']
session_id = payload.get('sid') # Optional: specific session
# Invalidate all sessions for this user
db.sessions.delete_by_user(user_id)
redis.delete(f'user_sessions:{user_id}')
return '', 200 # 200 = acknowledged
Security Best Practices
CSRF Protection
// Express.js: CSRF protection with csurf middleware
const csrf = require('csurf');
const csrfProtection = csrf({
cookie: {
httpOnly: true,
secure: true,
sameSite: 'strict',
},
});
app.get('/form', csrfProtection, (req, res) => {
// Include CSRF token in the form
res.render('form', { csrfToken: req.csrfToken() });
});
app.post('/transfer', csrfProtection, (req, res) => {
// csurf automatically validates the token
// If invalid, returns 403 Forbidden
processTransfer(req.body);
});
XSS Prevention for Token Storage
// Content Security Policy header — prevent inline scripts and external injection
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' 'nonce-{random}'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"connect-src 'self' https://api.example.com; " +
"frame-ancestors 'none';"
);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
next();
});
Complete Security Checklist
| Category | Practice | Priority |
|---|---|---|
| Transport | HTTPS everywhere (HSTS header with max-age=31536000) | Critical |
| Tokens | Short access token lifetime (5-15 min) | Critical |
| Tokens | Refresh token rotation with reuse detection | Critical |
| Storage | httpOnly + Secure + SameSite cookies for tokens | Critical |
| Validation | Whitelist algorithms (never accept none) | Critical |
| Validation | Verify iss, aud, exp, nbf on every request | Critical |
| CSRF | State parameter in OAuth flows + CSRF tokens for forms | High |
| XSS | Content Security Policy (CSP) headers | High |
| CORS | Strict origin whitelist (never * with credentials) | High |
| Rate Limiting | Rate-limit /login, /token, /register endpoints | High |
| Logging | Log all authentication events (successes and failures) | Medium |
| Monitoring | Alert on unusual patterns (brute force, token reuse) | Medium |
Case Studies
Case Study 1: Google Sign-In
Google's identity platform is one of the largest OIDC implementations in the world, handling billions of authentications per day. Their implementation is notable for several design decisions:
- Key rotation: Google rotates its RSA signing keys roughly every 6 hours. The JWKS endpoint (
https://www.googleapis.com/oauth2/v3/certs) always contains at least two keys — the current key and the previous key — to allow for clock skew and cache staleness. - ID token claims: Google's ID tokens include
hd(hosted domain) for Google Workspace users,email_verifiedfor email confirmation status, andat_hashfor binding the ID token to the access token. - One Tap sign-in: Introduced in 2020, this allows users to sign in with a single click using a pre-loaded credential, eliminating the redirect-based flow for returning users. It uses the FedCM (Federated Credential Management) API in modern browsers.
// Google Sign-In with the new Google Identity Services library
// (replaces the deprecated gapi.auth2)
google.accounts.id.initialize({
client_id: 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com',
callback: handleCredentialResponse,
auto_select: true, // Auto sign-in for returning users
});
function handleCredentialResponse(response) {
// response.credential is a JWT ID token
const idToken = response.credential;
// Send to your backend for verification
fetch('/api/auth/google', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idToken }),
});
}
// Render the sign-in button
google.accounts.id.renderButton(
document.getElementById('google-signin'),
{ theme: 'outline', size: 'large', text: 'continue_with' }
);
Case Study 2: Auth0 Implementation
Auth0 (now part of Okta) provides authentication-as-a-service for thousands of companies including Mozilla, Siemens, and Docker. Their architecture separates the Universal Login page (hosted on Auth0's domain) from the application, which means credentials never touch the application's servers.
Auth0's key innovation was making complex auth features accessible through configuration rather than code: multi-factor authentication (MFA), social logins, passwordless login, and adaptive risk assessment are all toggles in a dashboard. Under the hood, Auth0 uses OIDC-compliant flows with PKCE by default for all SPAs.
Case Study 3: GitHub OAuth Apps
GitHub's OAuth implementation powers thousands of developer tools — CI/CD pipelines (GitHub Actions, CircleCI), IDEs (VS Code, JetBrains), and services (Vercel, Netlify). Their approach demonstrates the power of fine-grained scopes.
# GitHub OAuth: Register an OAuth App at
# https://github.com/settings/developers
# Step 1: Redirect user to GitHub's authorization page
# https://github.com/login/oauth/authorize?
# client_id=YOUR_CLIENT_ID&
# redirect_uri=https://app.example.com/callback&
# scope=repo%20read:user&
# state=RANDOM_STATE
# Step 2: Exchange code for access token
curl -X POST https://github.com/login/oauth/access_token \
-H "Accept: application/json" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "code=AUTH_CODE_FROM_CALLBACK"
# Response:
# {"access_token":"gho_abc123","token_type":"bearer","scope":"repo,read:user"}
# Step 3: Use the token to access the GitHub API
curl -H "Authorization: Bearer gho_abc123" \
https://api.github.com/user
# GitHub scopes are granular:
# repo — full access to private repos
# read:user — read user profile
# user:email — read user email addresses
# admin:org — manage organizations
Exercises
Decode a JWT Manually
Go to jwt.io and paste a JWT from a public example. Identify all three parts (header, payload, signature). Decode the payload manually using atob() in your browser console. List all the claims and explain what each one means. Then modify the payload (change the sub claim) and observe how the signature becomes invalid.
Build an OAuth 2.0 + PKCE Login Flow
Create a single-page application that implements the Authorization Code flow with PKCE. Use GitHub as the authorization server. Your app should: (1) generate a code_verifier and code_challenge, (2) redirect to GitHub's authorize endpoint, (3) handle the callback and exchange the code for tokens, (4) display the user's profile information. Use only the Fetch API — no OAuth libraries.
Implement Refresh Token Rotation with Reuse Detection
Build a token endpoint that implements refresh token rotation: each time a refresh token is used, issue a new one and invalidate the old one. Then implement reuse detection: if a previously-used refresh token is presented again, revoke all tokens in that family (indicating theft). Use a database to track token families and their states. Write tests that simulate both normal rotation and a token theft scenario.
Authentication Architecture Generator
Use this tool to document your authentication architecture — provider, flow type, token strategy, scopes, and security decisions. Download as Word, Excel, PDF, or PowerPoint for architecture review, security audits, or team documentation.
Document your authentication architecture for export. All data stays in your browser — nothing is sent to any server.
All data stays in your browser. Nothing is sent to or stored on any server.
Conclusion & Resources
Authentication and authorization are among the most critical aspects of any application. A poorly implemented auth system can expose user data, enable account takeovers, and destroy trust. The standards covered in this guide — OAuth 2.0, JWT, PKCE, and OpenID Connect — represent decades of collective security engineering wisdom.
- OAuth 2.0 is an authorization framework — use OIDC on top for authentication
- Always use Authorization Code + PKCE for user-facing applications
- JWTs are self-contained — validate signature, issuer, audience, and expiry on every request
- Never accept the
nonealgorithm — always whitelist allowed algorithms - Store refresh tokens in httpOnly cookies, access tokens in memory
- Implement refresh token rotation with reuse detection
- The Implicit Grant is deprecated — migrate existing apps to Auth Code + PKCE
Further Learning Resources
Recommended Resources
- Spec: RFC 6749 — OAuth 2.0 Authorization Framework
- Spec: RFC 7519 — JSON Web Token (JWT)
- Spec: RFC 7636 — PKCE
- Guide: oauth.net/2 — community-maintained OAuth 2.0 reference
- Tool: jwt.io — JWT debugger and library listing
- Book: "OAuth 2 in Action" by Justin Richer & Antonio Sanso (Manning)
- Course: Auth0's "Identity Fundamentals" (free)