Authentication Deep Dive
Sessions, JWT, OAuth2, OpenID Connect, Refresh Tokens
Introduction
Authentication answers the question: "Who are you?" It is the first line of access control for every application. This lesson covers three major authentication patterns, session-based, token-based (JWT), and delegated (OAuth2), with Python code for each. You'll implement JWT token creation, validation, and refresh flows, and understand when to choose each pattern.
Session-Based Authentication
How It Works
- User submits username + password
- Server verifies credentials
- Server creates a session in storage (DB/Redis)
- Server returns a session ID cookie
- Client sends session ID in every request
- Server looks up session ID to get user data
Trade-offs
- ✅ Instant revocation, delete session from store
- ✅ Simple mental model
- ❌ Requires server-side storage
- ❌ Doesn't scale horizontally without sticky sessions or shared store
- ❌ CSRF vulnerability if not protected
# session_auth.py, Minimal session management (no framework)
import secrets
import hashlib
from datetime import datetime, timezone, timedelta
from dataclasses import dataclass, field
@dataclass
class Session:
user_id: int
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
expires_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc) + timedelta(hours=24))
def is_expired(self) -> bool:
return datetime.now(timezone.utc) > self.expires_at
# In-memory session store (use Redis in production)
SESSION_STORE: dict[str, Session] = {}
def create_session(user_id: int) -> str:
"""Create a new session and return the session token."""
token = secrets.token_urlsafe(32) # 256-bit random token
SESSION_STORE[token] = Session(user_id=user_id)
return token
def get_session(token: str) -> Session | None:
"""Validate a session token and return the session."""
session = SESSION_STORE.get(token)
if session is None or session.is_expired():
SESSION_STORE.pop(token, None)
return None
return session
def invalidate_session(token: str) -> None:
"""Logout: delete the session."""
SESSION_STORE.pop(token, None)
# Simulation
token = create_session(user_id=42)
print(f"Session token: {token[:16]}...")
session = get_session(token)
print(f"User ID: {session.user_id}")
print(f"Expires: {session.expires_at.strftime('%H:%M %Z')}")
invalidate_session(token)
print(f"After logout: {get_session(token)}") # NoneJWT, JSON Web Tokens
JWTs are self-contained tokens that encode the user's identity and claims directly in the token. The server verifies the signature without needing to look anything up in a database.
JWT Structure
header.b64.payload.b64.signature.b64Create & Sign JWT
Install: pip install PyJWT cryptography
# jwt_create.py
import jwt
import secrets
from datetime import datetime, timezone, timedelta
# For symmetric signing (HS256), shared secret
SECRET_KEY = secrets.token_hex(32)
def create_access_token(user_id: int, roles: list[str]) -> str:
"""Create a short-lived access token."""
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id), # Subject (user ID)
"iat": now, # Issued at
"exp": now + timedelta(minutes=15), # Expires in 15 min
"roles": roles,
"type": "access",
}
return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
def create_refresh_token(user_id: int) -> str:
"""Create a long-lived refresh token."""
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"iat": now,
"exp": now + timedelta(days=30),
"type": "refresh",
}
return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
access_token = create_access_token(42, roles=["user", "admin"])
refresh_token = create_refresh_token(42)
print(f"Access token: {access_token[:50]}...")
print(f"Refresh token: {refresh_token[:50]}...")
Expected Output:
Access token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0M... Refresh token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0M...
Verify JWT
# jwt_verify.py, continued
def verify_token(token: str, expected_type: str = "access") -> dict | None:
"""
Verify a JWT. Returns payload if valid, None if invalid.
Raises are caught internally, never let JWT errors leak to clients.
"""
try:
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=["HS256"],
options={"require": ["exp", "iat", "sub"]},
)
if payload.get("type") != expected_type:
return None
return payload
except jwt.ExpiredSignatureError:
print("Token expired")
return None
except jwt.InvalidTokenError as e:
print(f"Invalid token: {e}")
return None
# Verify the access token
payload = verify_token(access_token, "access")
if payload:
print(f"Authenticated: user_id={payload['sub']}, roles={payload['roles']}")
# Tampered token, signature verification FAILS
tampered = access_token[:-10] + "AAAAAAAAAA"
result = verify_token(tampered)
print(f"Tampered token: {result}") # None
# Algorithm confusion attack, always specify algorithms explicitly
try:
jwt.decode(access_token, SECRET_KEY, algorithms=["none"])
except Exception as e:
print(f"Algorithm=none rejected: {type(e).__name__}")
Expected Output:
Authenticated: user_id=42, roles=['user', 'admin'] Invalid token: Signature verification failed Tampered token: None Algorithm=none rejected: InvalidAlgorithmError
Token Refresh Pattern
# token_refresh.py, Token refresh + revocation via blocklist
import time
# In production: use Redis SET with TTL for the blocklist
REFRESH_TOKEN_BLOCKLIST: set[str] = set()
def refresh_access_token(refresh_token: str) -> str | None:
"""Exchange a valid refresh token for a new access token."""
# Check blocklist first (revoked tokens)
if refresh_token in REFRESH_TOKEN_BLOCKLIST:
print("Refresh token revoked")
return None
payload = verify_token(refresh_token, expected_type="refresh")
if payload is None:
return None
user_id = int(payload["sub"])
# In production: look up user roles from database
return create_access_token(user_id, roles=["user"])
def logout(refresh_token: str) -> None:
"""Revoke a refresh token."""
REFRESH_TOKEN_BLOCKLIST.add(refresh_token)
# Simulate refresh flow
new_access = refresh_access_token(refresh_token)
print(f"New access token: {new_access[:40] if new_access else None}...")
# Logout, revoke refresh token
logout(refresh_token)
result = refresh_access_token(refresh_token)
print(f"After logout: {result}") # None
Expected Output:
Authenticated: user_id=42, roles=['user', 'admin'] New access token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ... Refresh token revoked After logout: None
OAuth2 & OpenID Connect
| Grant Type | Use Case | Involves Human? |
|---|---|---|
| Authorization Code + PKCE | Web/mobile apps accessing APIs on behalf of users | ✅ Yes (browser redirect) |
| Client Credentials | Machine-to-machine (service accounts, cron jobs) | ❌ No |
| Device Code | Smart TVs, CLI tools with limited input | ✅ Out-of-band |
| Refresh Token | Obtaining new access tokens silently | ❌ No |
Install: pip install requests
# oauth2_client_credentials.py, Machine-to-machine auth
import requests
def get_oauth2_token(
token_url: str,
client_id: str,
client_secret: str,
scope: str = "",
) -> dict:
"""
OAuth2 Client Credentials flow.
No user interaction required, used for service accounts.
"""
response = requests.post(
token_url,
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": scope,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10,
)
response.raise_for_status()
return response.json()
def call_api_with_token(api_url: str, access_token: str) -> dict:
"""Call a protected API using the Bearer token."""
response = requests.get(
api_url,
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
)
response.raise_for_status()
return response.json()
# Example usage (requires a real OAuth2 server)
# token_data = get_oauth2_token(
# token_url="https://auth.example.com/oauth/token",
# client_id="my-service",
# client_secret="secret-from-secrets-manager",
# scope="read:data write:data",
# )
# print(f"Access token type: {token_data['token_type']}")
# print(f"Expires in: {token_data['expires_in']} seconds")
# result = call_api_with_token("https://api.example.com/data", token_data["access_token"])
print("OAuth2 Client Credentials pattern: POST grant_type=client_credentials")
print("Token stored in memory (not disk), re-fetched when expired")
print()
print("OpenID Connect adds:")
print(" • id_token (JWT with user identity)")
print(" • /userinfo endpoint for profile data")
print(" • Discovery endpoint (.well-known/openid-configuration)")
Expected Output:
OAuth2 Client Credentials pattern: POST grant_type=client_credentials Token stored in memory (not disk), re-fetched when expired OpenID Connect adds: • id_token (JWT with user identity) • /userinfo endpoint for profile data • Discovery endpoint (.well-known/openid-configuration)
Key Takeaways
- Sessions require server storage, easy revocation, but requires shared session store for horizontal scaling
- JWTs are stateless, no server lookup required, but revocation is complex (use short expiry + refresh tokens)
- Always specify algorithms explicitly,
algorithms=["HS256"]prevents algorithm confusion attacks - Access tokens should be short-lived, 15 minutes is typical; use refresh tokens for continuity
- OAuth2 is for delegation, "let this app act on your behalf"; not a direct authentication protocol
- OpenID Connect adds identity to OAuth2, the
id_tokenproves who the user is
What's Next?
Lesson 11 answers the follow-up question: once you know who someone is, what are they allowed to do?
- RBAC, Role-Based Access Control with Python decorators
- ABAC, Attribute-Based Access Control for fine-grained policies
- OPA, Open Policy Agent as a policy engine