Back to Technology

Authentication: OAuth 2.0 & JWT

April 1, 2026 Wasil Zafar 50 min read

Master modern authentication — OAuth 2.0 authorization flows, JWT token structure and validation, refresh token rotation, PKCE for public clients, OpenID Connect, session management, and security best practices for web and mobile applications.

Table of Contents

  1. History of Web Authentication
  2. OAuth 2.0 Overview
  3. Authorization Code Flow
  4. PKCE for Public Clients
  5. JWT Token Structure
  6. JWT Validation & Security
  7. Refresh Token Strategy
  8. OpenID Connect
  9. Session Management
  10. Security Best Practices
  11. Case Studies
  12. Exercises
  13. Authentication Architecture Generator
  14. Conclusion & Resources

History of Web Authentication

Key Insight: Authentication answers "who are you?" while authorization answers "what are you allowed to do?" OAuth 2.0 is primarily an authorization framework, and OpenID Connect adds an authentication layer on top of it.

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
1996HTTP Basic Auth (RFC 1945)First standardized web authentication
1997HTTP Cookies (RFC 2109)Stateful sessions in a stateless protocol
1999HTTP Digest Auth (RFC 2617)Challenge-response without plaintext passwords
2005SAML 2.0 (OASIS)Enterprise SSO with XML assertions
2007OAuth 1.0Delegated authorization with crypto signatures
2012OAuth 2.0 (RFC 6749)Simplified delegation with bearer tokens over TLS
2014OpenID Connect 1.0Identity layer on OAuth 2.0
2015JWT (RFC 7519)Self-contained, signed token format
2019OAuth 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 OwnerThe user who owns the data and grants accessYou (the person clicking "Allow")
ClientThe application requesting access on the user's behalfA mobile app, SPA, or backend service
Authorization ServerIssues tokens after authenticating the user and obtaining consentGoogle Accounts, Auth0, Keycloak
Resource ServerHosts the protected resources, validates tokensGoogle Drive API, GitHub API

Grant Types

Grant Type Use Case Status in OAuth 2.1
Authorization CodeServer-side web appsRecommended (with PKCE)
Authorization Code + PKCESPAs, mobile apps, all public clientsMandatory for public clients
Client CredentialsMachine-to-machine (no user)Retained
Device CodeSmart TVs, CLI tools (limited input)Retained
ImplicitSPAs (legacy)Removed — use Auth Code + PKCE
Resource Owner PasswordTrusted first-party apps (legacy)Removed — use Auth Code
Warning: The Implicit Grant and Resource Owner Password Grant are both deprecated in the OAuth 2.1 draft. If you are building a new application, never use these flows. Use Authorization Code with PKCE for all client-facing apps and Client Credentials for server-to-server communication.

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
1UserClientUser clicks "Login with Google"
2ClientAuth ServerRedirect to /authorize with client_id, redirect_uri, scope, state
3Auth ServerUserShows consent screen ("App X wants to access your email")
4UserAuth ServerUser approves the request
5Auth ServerClientRedirects to redirect_uri with code and state
6Client (backend)Auth ServerPOST to /token with code, client_id, client_secret
7Auth ServerClient (backend)Returns access_token, refresh_token, id_token
8Client (backend)Resource ServerAPI 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']}")
Key Insight: The 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;
}
Key Insight: The OAuth 2.1 draft mandates PKCE for all authorization code flows, not just public clients. Even confidential clients with a client_secret should use PKCE as defense-in-depth. The cost is negligible (one SHA-256 hash) and the security benefit is substantial.

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
issIssuerWho issued the token (auth server URL)
subSubjectWho the token is about (user ID)
audAudienceWho the token is intended for (API URL)
expExpirationWhen the token expires (Unix timestamp)
iatIssued AtWhen the token was issued
nbfNot BeforeToken is not valid before this time
jtiJWT IDUnique token identifier (for revocation)

Signing Algorithms

Algorithm Type Use Case
HS256Symmetric (HMAC-SHA256)Same key for signing and verification — simple but key must be shared
RS256Asymmetric (RSA-SHA256)Private key signs, public key verifies — ideal for distributed systems
ES256Asymmetric (ECDSA-SHA256)Smaller keys, same security as RS256 — recommended for new systems
noneNo signatureNEVER use in production — has caused major vulnerabilities
Warning: The "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"
        }
    ]
}
Key Insight: Always cache JWKS keys with a TTL (e.g., 1 hour) and have a fallback to re-fetch when a token's 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
localStorageNoYesNever store refresh tokens here
sessionStorageNoYesAcceptable for short-lived access tokens
httpOnly cookieYesNo (need CSRF token)Best for refresh tokens
In-memory (JS variable)YesYesBest for access tokens (lost on refresh)
httpOnly + SameSite=StrictYesYesBest overall for both token types
Warning: Never store refresh tokens in localStorage or sessionStorage. Any XSS vulnerability in your application (or in any third-party script you include) could steal these tokens. Use httpOnly cookies with the Secure and SameSite=Strict flags.

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.

Key Insight: For most web applications, the combination of refresh token rotation + sliding window + reuse detection provides the best balance of security and user experience. Active users never see a login screen (up to the absolute max lifetime), while stolen tokens are detected and revoked automatically.

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"]
}
Key Insight: The discovery document is the starting point for any OIDC integration. Fetch it once at startup (and cache it), and your application automatically knows all the endpoints, supported algorithms, and available scopes — no hardcoding required.

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)
StateServer stores session data (Redis, DB)Client holds self-contained token
ScalabilityRequires shared session storeStateless — any server can validate
RevocationEasy — delete from storeHard — token is valid until expiry
Storage sizeSmall cookie (session ID only)Larger (JWT contains all claims)
LogoutImmediate — session destroyedNot immediate — token remains valid
Mobile-friendlyLess (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

Key Insight: Security is not a feature you add at the end — it is a property of the entire system. Every design decision in your authentication architecture (token lifetime, storage method, algorithm choice) has security implications.

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
TransportHTTPS everywhere (HSTS header with max-age=31536000)Critical
TokensShort access token lifetime (5-15 min)Critical
TokensRefresh token rotation with reuse detectionCritical
StoragehttpOnly + Secure + SameSite cookies for tokensCritical
ValidationWhitelist algorithms (never accept none)Critical
ValidationVerify iss, aud, exp, nbf on every requestCritical
CSRFState parameter in OAuth flows + CSRF tokens for formsHigh
XSSContent Security Policy (CSP) headersHigh
CORSStrict origin whitelist (never * with credentials)High
Rate LimitingRate-limit /login, /token, /register endpointsHigh
LoggingLog all authentication events (successes and failures)Medium
MonitoringAlert 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_verified for email confirmation status, and at_hash for 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

Exercise 1 Beginner

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.

JWT Structure Base64 Claims
Exercise 2 Intermediate

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.

PKCE OAuth 2.0 SPA Crypto API
Exercise 3 Advanced

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.

Security Token Rotation Revocation Database Design

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.

Authentication Architecture Generator

Document your authentication architecture for export. All data stays in your browser — nothing is sent to any server.

Draft auto-saved

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.

Key Takeaways:
  • 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 none algorithm — 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

Technology