Authentication Design Patterns
Implements production-grade authentication architectures that cover the full token lifecycle — from credential verification through token issuance, validation, refresh, and revocation. When loaded, this skill makes the model design secure authentication flows with explicit security considerations at every step: proper secret management, token expiry strategies, CSRF protection, and secure cookie configuration.
TL;DR Checklist
- Choose auth mechanism matching threat model (JWT for stateless, sessions for stateful)
- Never store plaintext passwords — always use Argon2id or Bcrypt with per-user salt
- JWT access tokens: short-lived (5–15 min), refresh tokens: longer with rotation and revocation
- Always set
Secure,HttpOnly,SameSite=Stricton cookies carrying auth data - Implement token revocation for login/logout and account compromise scenarios
- Add MFA/TOTP as an optional but encouraged second factor for sensitive operations
When to Use
Use this skill when:
- Designing authentication from scratch for a new API or web application
- Migrating from basic auth (HTTP Basic, API keys) to token-based authentication (JWT, OAuth)
- Implementing multi-factor authentication (MFA/TOTP) on an existing login system
- Building a third-party OAuth 2.0 / OIDC integration (Google, GitHub, Microsoft login)
- Designing session management for a stateful web application with secure cookie configuration
- Conducting a security audit of authentication flows and token handling patterns
When NOT to Use
Avoid this skill for:
- Implementing authorization/permissions (who can do what) — use
api-gateway-patternsor framework-specific auth likespring-security-coreinstead - Building the UI login form itself — this covers server-side auth, not frontend components
- One-off script authentication (e.g., simple API key for a CLI tool) — overkill; use a raw environment variable with an env guard
- Cryptographic algorithm design — never invent your own crypto; use established libraries
Core Workflow
Classify the Authentication Model — Determine whether the system needs stateless (JWT tokens), stateful (server-side sessions), or hybrid (JWT + refresh tokens) authentication. Stateless suits distributed microservices and mobile clients; stateful suits traditional web apps with browser sessions; hybrid gives you the best of both worlds for API clients. Checkpoint: Every client type must have its auth model documented — e.g., "web browser = session cookies", "mobile app = JWT + refresh token rotation", "service-to-service = mTLS or JWT audience restriction".
Design Credential Storage — Choose password hashing: Argon2id for new systems (preferred, memory-hard), Bcrypt with cost factor >= 12 for legacy compatibility. Store a unique per-user salt (Argon2 generates it automatically; Bcrypt embeds the salt in its output). Hash on every login attempt with constant-time comparison. Checkpoint: Verify that no password is ever stored in plaintext or reversible encryption — base64 encoding does not count as hashing. Every new user registration must produce exactly one hashed password value, never two.
Implement Token Issuance — Create the login endpoint that accepts credentials, verifies them against the hash, and returns tokens. For JWT: generate access token (5-15 min expiry,
iss,sub,exp,jticlaims) and refresh token (7-30 day expiry, stored in a rotating token store). Sign JWTs with RS256 or EdDSA (never HS256 in distributed systems — the public key must be separate from the secret). Checkpoint: After login returns, verify that the access token contains only claims needed for authorization (no PII), and that the refresh token has a uniquejtiidentifier stored server-side.Build Token Validation Pipeline — Implement middleware/handler that validates tokens on every protected request. For JWT: verify signature, check
exp, validateaudandiss, checkjtiagainst revocation list if present. For sessions: lookup session ID in the store, verify not expired, regenerate session ID periodically (rotation). Checkpoint: Every validation failure must return HTTP 401 with a generic message — never reveal whether the username or password was wrong, or whether the token is expired vs invalidly signed.Implement Token Refresh and Rotation — When an access token expires, allow clients to exchange a valid refresh token for a new pair (new access + new refresh). Implement rotation: after each refresh, invalidate the old refresh token and issue a new one with a fresh
jti. Track total refresh count per session; revoke all tokens on suspicious activity. Checkpoint: The refresh endpoint must be rate-limited independently from the login endpoint, and every successful refresh must emit an audit log entry with client IP and user agent.Design Logout and Revocation — For stateful sessions: delete the session from the store and clear the cookie. For JWT: maintain a revocation list (Redis-backed set of
jtivalues) checked during validation, or use short access token lifetimes so revoked tokens expire within minutes. Checkpoint: After logout, verify that the same token/session cannot be used to access any protected resource — this includes cached pages and background API calls from single-page applications.
Implementation Patterns
Pattern 1: JWT Token Lifecycle with RS256 Signing
Production-grade JWT authentication using RS256 (asymmetric) signing, short-lived access tokens, rotating refresh tokens, and a Redis-backed revocation store. This pattern covers the complete flow: login -> token issuance -> validation -> refresh -> revocation.
"""authentication/jwt_auth.py - Production JWT authentication with RS256 signing."""
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone, timedelta
from typing import Optional
import jwt # PyJWT library
@dataclass(frozen=True)
class TokenPair:
"""Access + refresh token pair returned after successful authentication."""
access_token: str
refresh_token: str
refresh_token_jti: str
issued_at: datetime
expires_in: int # seconds until access token expires
@dataclass(frozen=True)
class JWTPayload:
"""Decoded JWT payload with validated claims."""
sub: str
jti: str
aud: str
iss: str
exp: datetime
iat: datetime
nbf: Optional[datetime] = None
scopes: list[str] = field(default_factory=list)
class JWTAuthProvider:
"""Manages JWT token lifecycle with RS256 asymmetric signing.
Uses a key pair (private/public key). The private key signs tokens;
the public key verifies them. This allows distributed systems to
verify tokens without sharing the signing secret - each service
holds only the public key.
"""
def __init__(
self,
private_key_path: str,
public_key_path: str,
audience: str,
issuer: str,
access_token_ttl_seconds: int = 900, # 15 minutes
refresh_token_ttl_days: int = 7,
) -> None:
with open(private_key_path, "r") as f:
self._private_key = f.read()
with open(public_key_path, "r") as f:
self._public_key = f.read()
self._audience = audience
self._issuer = issuer
self._access_ttl = access_token_ttl_seconds
self._refresh_ttl = timedelta(days=refresh_token_ttl_days)
def generate_tokens(self, user_id: str, scopes: list[str] | None = None) -> TokenPair:
"""Issue a new JWT access token and refresh token pair.
Args:
user_id: The authenticated user's unique identifier (claims in `sub`).
scopes: Optional list of permission scopes granted to this token.
Returns:
TokenPair with both tokens and metadata for client storage.
"""
now = datetime.now(timezone.utc)
access_jti = str(uuid.uuid4())
# Short-lived access token - only carries minimal claims
access_payload: dict = {
"sub": user_id,
"jti": access_jti,
"aud": self._audience,
"iss": self._issuer,
"iat": now,
"exp": now + timedelta(seconds=self._access_ttl),
}
if scopes:
access_payload["scopes"] = scopes
access_token = jwt.encode(
access_payload,
self._private_key,
algorithm="RS256",
)
# Long-lived refresh token with unique JTI for rotation tracking
refresh_jti = str(uuid.uuid4())
refresh_payload: dict = {
"sub": user_id,
"jti": refresh_jti,
"aud": self._audience,
"iss": self._issuer,
"iat": now,
"exp": now + self._refresh_ttl,
"type": "refresh", # Distinguishes refresh tokens from access tokens
}
refresh_token = jwt.encode(
refresh_payload,
self._private_key,
algorithm="RS256",
)
return TokenPair(
access_token=access_token,
refresh_token=refresh_token,
refresh_token_jti=refresh_jti,
issued_at=now,
expires_in=self._access_ttl,
)
def decode_access_token(self, token: str, revocation_store: set[str]) -> JWTPayload:
"""Validate and decode a JWT access token.
Checks signature, expiration, issuer, audience, and revocation status.
Raises jwt.PyJWTError on any validation failure.
Args:
token: The raw JWT string from the Authorization header.
revocation_store: Set of revoked JTI values (checked during validation).
Returns:
JWTPayload with all validated claims.
Raises:
jwt.ExpiredSignatureError: If token has expired.
jwt.InvalidIssuerError: If issuer does not match configured value.
jwt.InvalidAudienceError: If audience does not match configured value.
jwt.InvalidTokenError: On any other validation failure.
"""
# Verify RS256 signature using public key
payload = jwt.decode(
token,
self._public_key,
algorithms=["RS256"],
audience=self._audience,
issuer=self._issuer,
)
# Check JTI against revocation store
if payload["jti"] in revocation_store:
raise jwt.InvalidTokenError("Token has been revoked")
return JWTPayload(
sub=payload["sub"],
jti=payload["jti"],
aud=payload["aud"],
iss=payload["iss"],
exp=payload["exp"],
iat=payload["iat"],
nbf=payload.get("nbf"),
scopes=payload.get("scopes", []),
)
def rotate_refresh_token(
self,
old_refresh: str,
revocation_store: set[str],
) -> TokenPair:
"""Rotate a refresh token: invalidate old, issue new pair.
Implements token rotation - each refresh consumes the old refresh token
and produces a fresh pair. The old refresh token's JTI is added to the
revocation store so it cannot be replayed even if intercepted.
Args:
old_refresh: The current valid refresh token from the client.
revocation_store: Set of revoked JTI values.
Returns:
New TokenPair with fresh access + refresh tokens.
"""
# Decode old refresh token to extract user info
old_payload = jwt.decode(
old_refresh,
self._public_key,
algorithms=["RS256"],
audience=self._audience,
issuer=self._issuer,
)
if old_payload.get("type") != "refresh":
raise ValueError("Provided token is not a refresh token")
# Revoke old refresh token by adding its JTI to revocation store
old_jti = old_payload["jti"]
revocation_store.add(old_jti)
# Issue new token pair with the same user context
return self.generate_tokens(user_id=old_payload["sub"])
Pattern 2: Password Hashing with Argon2id (BAD vs. GOOD)
Demonstrates correct password hashing using Argon2id (the winner of the 2015 Password Hashing Competition), contrasted with common anti-patterns.
"""authentication/password_hash.py - Secure password storage patterns."""
from __future__ import annotations
# --- BAD: Multiple common anti-patterns in one block ---
def bad_password_hash(password: str) -> str:
"""BAD: Uses SHA-256 with no salt - vulnerable to rainbow table attacks."""
import hashlib
# No salt, no iterations, no memory cost - instant to crack
return hashlib.sha256(password.encode()).hexdigest()
def bad_password_verify(stored_hash: str, password: str) -> bool:
"""BAD: Timing attack vulnerability - Python `==` short-circuits on first mismatch."""
import hashlib
computed = hashlib.sha256(password.encode()).hexdigest()
return stored_hash == computed # Vulnerable to timing attacks
# --- GOOD: Argon2id with recommended parameters ---
def hash_password(password: str, salt: bytes | None = None) -> str:
"""Hash a password using Argon2id with secure default parameters.
Uses the OWASP-recommended configuration:
- Memory: 64 MB (tunable based on server capacity)
- Iterations: 3
- Parallelism: 4
- Hash length: 32 bytes
Args:
password: The plaintext password to hash. Must be <= 4096 characters.
salt: Optional pre-generated salt (16 bytes recommended). If None,
Argon2id generates a random one internally.
Returns:
The encoded hash string containing algorithm params, salt, and hash
in the standard $argon2id$v=19$ format (parseable by any Argon2 library).
Raises:
ValueError: If password is empty or exceeds 4096 characters.
"""
if not password or len(password) > 4096:
raise ValueError("Password must be between 1 and 4096 characters")
try:
import argon2
except ImportError:
raise RuntimeError(
"argon2-cffi package required. Install with: pip install argon2-cffi"
)
ph = argon2.PasswordHasher(
time_cost=3, # Number of iterations
memory_cost=65536, # 64 MB memory (65536 KB)
parallelism=4, # Number of parallel threads
hash_len=32, # Output hash length in bytes
salt_len=16, # Random salt length in bytes
type=argon2.Type.ID, # Argon2id - hybrid of argon2i and argon2d
)
return ph.hash(password, salt=salt)
def verify_password(stored_hash: str, password: str) -> bool:
"""Verify a plaintext password against a stored Argon2id hash.
Uses constant-time comparison internally (argon2-cffi library handles this).
The `check_needs_rehash` flag enables transparent re-hashing when parameters
change - if the server upgrades its Argon2 config, passwords are silently
upgraded on next login.
Args:
stored_hash: The hash string previously returned by `hash_password()`.
Must be in $argon2id$v=19$ format.
password: The plaintext password to verify.
Returns:
True if the password matches, False otherwise.
Raises:
argon2.exceptions.VerifyMismatchError: If hash format is invalid.
"""
try:
import argon2
except ImportError:
raise RuntimeError(
"argon2-cffi package required. Install with: pip install argon2-cffi"
)
ph = argon2.PasswordHasher()
try:
# check_needs_rehash=True enables transparent parameter upgrades
needs_rehash = ph.verify(stored_hash, password)
if needs_rehash:
# Re-hash with updated parameters on next login (silent upgrade)
new_hash = ph.hash(password)
_update_password_in_database(stored_hash, new_hash)
return True
except argon2.exceptions.VerifyMismatchError:
return False
def _update_password_in_database(old_hash: str, new_hash: str) -> None:
"""Update a password hash in the database atomically.
Production implementation pattern using SQLAlchemy-style direct SQL execution.
Uses optimistic concurrency control: the WHERE clause matches both the user's
primary key AND their current hash value, ensuring atomicity without explicit
transactions. If another request updated the password concurrently (returning 0 rows),
an IntegrityError is raised to signal a race condition.
Args:
old_hash: The previously stored hash value used as a concurrency guard.
new_hash: The new Argon2id hash to store.
Raises:
IntegrityError: If the password was changed by another concurrent request
(old_hash no longer matches, meaning someone else updated it first).
"""
from sqlalchemy.exc import IntegrityError
session = SessionLocal()
try:
result = session.execute(
"UPDATE users SET password_hash = :new_hash WHERE id = :user_id AND password_hash = :old_hash",
{"new_hash": new_hash, "user_id": _get_current_user_id(), "old_hash": old_hash},
)
if result.rowcount == 0:
raise IntegrityError(
"UPDATE users SET password_hash = :new_hash WHERE id = :user_id AND password_hash = :old_hash",
{"new_hash": new_hash, "user_id": _get_current_user_id(), "old_hash": old_hash},
None,
)
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
Pattern 3: Secure Session Cookie Configuration
Proper session cookie settings that prevent XSS theft, CSRF attacks, and cross-site tracking.
"""authentication/session_cookies.py - Secure session cookie configuration."""
from http import cookies
from datetime import datetime, timedelta, timezone
import secrets
import re
def create_secure_session_cookie(
session_id: str,
ttl_minutes: int = 30,
domain: str | None = None,
same_site: str = "Strict",
) -> str:
"""Create a properly secured session cookie string.
Configures the following security attributes:
- Secure: Only sent over HTTPS (prevents network-level interception)
- HttpOnly: Not accessible via JavaScript (prevents XSS token theft)
- SameSite=Strict: Never sent on cross-site requests (prevents CSRF)
- Max-Age: Explicit expiration rather than session-based expiry
Args:
session_id: The opaque session identifier. Must be cryptographically random.
ttl_minutes: Session lifetime in minutes. Default 30 minutes for web apps.
Use shorter TTLs (5-15 min) for highly sensitive operations.
domain: Optional domain to scope the cookie to. If None, uses current host.
same_site: "Strict" (most secure), "Lax" (allows top-level GET navigations),
or "None" (must use with Secure flag only - not recommended).
Returns:
Cookie string formatted as `name=value; Secure; HttpOnly; SameSite=Strict`.
Raises:
ValueError: If same_site is not one of the allowed values.
"""
valid_samesite = {"Strict", "Lax", "None"}
if same_site not in valid_samesite:
raise ValueError(f"same_site must be one of {valid_samesite}")
max_age_seconds = ttl_minutes * 60
cookie_value = f"session={session_id}; Secure; HttpOnly; Max-Age={max_age_seconds}; Path=/"
if domain:
cookie_value += f"; Domain={domain}"
cookie_value += f'; SameSite={same_site}'
return cookie_value
def generate_secure_session_id() -> str:
"""Generate a cryptographically secure session identifier.
Uses os.urandom(32) which draws from the OS CSPRNG (Linux /dev/urandom,
Windows BCryptGenRandom). Output is URL-safe base64 encoded (no padding).
Produces 256 bits of entropy - sufficient to prevent session guessing even
against millions of concurrent sessions. Collision probability is negligible
per the birthday paradox at this entropy level.
Returns:
A 43-character URL-safe base64 string (e.g., "dGhpcyBpcyBhIHRlc3Q...").
"""
return secrets.token_urlsafe(32)
def validate_session_cookie(cookie_header: str, expected_session_id: str) -> bool:
"""Validate a session cookie against the expected ID using constant-time comparison.
Prevents timing attacks by using `secrets.compare_digest()` which takes
the same time regardless of how many characters match between inputs.
Args:
cookie_header: The full Cookie header value from the HTTP request.
Expected format: "session=<opaque_id>"
expected_session_id: The session ID to validate against.
Returns:
True if the cookie contains the expected session ID, False otherwise.
"""
match = re.search(r"session=([^;]+)", cookie_header)
if not match:
return False
provided_id = match.group(1).strip()
return secrets.compare_digest(provided_id, expected_session_id)
Pattern 4: TOTP Multi-Factor Authentication (RFC 6238)
Two-factor authentication using Time-based One-Time Passwords as defined in RFC 6238. Compatible with Google Authenticator, Authy, and other TOTP apps.
"""authentication/totp_mfa.py - RFC 6238 TOTP for multi-factor authentication."""
import hashlib
import hmac
import struct
import time
import base64
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class TOTPOptions:
"""Configuration for TOTP token generation and verification.
Follows RFC 6238 Section 4 recommendations:
- 160-bit (20-byte) shared secret
- 30-second time step
- SHA-1 hash function
- 6-digit codes (truncated to 8 digits max)
"""
secret_key: str # Base32-encoded shared secret stored per user
period_seconds: int = 30 # Time step (RFC 6238 default: 30 seconds)
digits: int = 6 # Code length (6-8, RFC 6238 Section 4: 6 recommended)
allowed_drift: int = 1 # How many time steps to look ahead/behind
def __post_init__(self) -> None:
if not (6 <= self.digits <= 8):
raise ValueError("TOTP digits must be between 6 and 8")
if self.period_seconds < 10 or self.period_seconds > 60:
raise ValueError("TOTP period must be between 10 and 60 seconds")
def generate_totp_secret() -> str:
"""Generate a new cryptographically random TOTP secret in Base32 encoding.
Produces 20 bytes (160 bits) of random data from os.urandom, then encodes
it in RFC 4648 Base32 format without padding. This produces a 32-character
string suitable for manual entry into authenticator apps or QR code encoding.
Returns:
A 32-character Base32-encoded secret (e.g., "JBSWY3DPEHPK3PXP").
"""
return base64.b32encode(__import__("os").urandom(20)).decode("ascii").rstrip("=")
def generate_totp_code(secret_b32: str, time_step: int | None = None, digits: int = 6) -> str:
"""Generate a TOTP code for the current or specified time step.
Implements RFC 6238 Section 5 (TOTP Algorithm):
1. Convert time to counter value: T = floor(current_time / time_step)
2. Convert T to 8-byte big-endian representation
3. Compute HMAC-SHA1(secret, T_bytes)
4. Dynamic truncation: extract 4 bytes from HMAC using offset byte
5. Extract numeric code: take last 31 bits and mod 10^digits
Args:
secret_b32: Base32-encoded shared secret (the value stored in the authenticator app).
time_step: Optional specific time step index (for testing or back-dating).
If None, uses current Unix time divided by period.
digits: Number of digits in the output code. Must be 6–8.
Returns:
A zero-padded numeric string of length `digits` (default "123456").
"""
secret_bytes = base64.b32decode(secret_b32.upper() + "==") # Add padding for decode
if time_step is None:
current_time = int(time.time())
time_step = current_time // 30
# Convert counter to 8-byte big-endian bytes
time_bytes = struct.pack(">Q", time_step)
# HMAC-SHA1(secret, time_bytes)
hmac_digest = hmac.new(secret_bytes, time_bytes, hashlib.sha1).digest()
# Dynamic truncation (RFC 4226 Section 5.4)
offset = hmac_digest[-1] & 0x0F
code_int = struct.unpack(">I", hmac_digest[offset:offset + 4])[0]
code_int &= 0x7FFFFFFF # Mask to 31 bits
# Generate N-digit code using configurable digits parameter
code = code_int % (10 ** digits)
return str(code).zfill(digits)
def verify_totp_code(secret_b32: str, code: str, options: TOTPOptions | None = None) -> bool:
"""Verify a TOTP code entered by the user.
Checks the provided code against codes generated for the current time step
and up to `allowed_drift` steps in either direction (to account for clock
skew between server and device). Returns False immediately if the format
is invalid.
Args:
secret_b32: Base32-encoded shared secret stored in the database.
code: The 6-digit code entered by the user.
options: Optional configuration overrides. Uses default RFC 6238 values if None.
Returns:
True if the code matches any valid code within the drift window.
"""
opts = options or TOTPOptions(secret_key=secret_b32)
if not code.isdigit() or len(code) != opts.digits:
return False
current_time = int(time.time())
base_step = current_time // opts.period_seconds
# Check current step and drift window (past + future)
for offset in range(-opts.allowed_drift, opts.allowed_drift + 1):
expected = generate_totp_code(secret_b32, time_step=base_step + offset, digits=opts.digits)
if hmac.compare_digest(expected, code):
return True
return False
Constraints
MUST DO
- Use RS256 or EdDSA for JWT signing — never HS256 in multi-service systems where the signing secret must be shared across services
- Set
Secure,HttpOnly, andSameSite=Stricton every cookie carrying session data or tokens - Generate all opaque identifiers (session IDs, JTI values) using a CSPRNG (
secrets.token_urlsafe(32)) — neverrandom.randint()or UUIDv4 for security-sensitive values - Hash passwords with Argon2id (preferred) or Bcrypt with cost factor >= 12 — never MD5, SHA-256, or any fast hash function for password storage
- Implement refresh token rotation: every refresh invalidates the old refresh token and issues a new pair — this limits the window for stolen refresh tokens
- Rate-limit both login and refresh endpoints independently to prevent brute-force credential stuffing attacks
- Store TOTP secrets with the same security as passwords (encrypted at rest) — they are effectively long-lived authentication keys
MUST NOT DO
- Never store passwords in plaintext, reversible encryption (AES), or base64 encoding — these all count as "no hashing"
- Never expose whether login failed due to wrong username vs wrong password — use the same error message for both to prevent user enumeration
- Never use
SameSite=Noneon session cookies withoutSecure— this is how cross-site request forgery attacks bypass SameSite protection - Never allow refresh token reuse across different clients — if a refresh token is used from an unrecognized IP or device, revoke all tokens and require re-authentication
- Never include user PII (email, name, address) in JWT access tokens — they are base64-encoded, not encrypted, and anyone with the token can read the claims
- Never implement custom cryptographic algorithms for authentication — always use established libraries and standard protocols (RFC 6238 for TOTP, RFC 7519 for JWT)
Output Template
When applying this skill, produce outputs following this structure:
- Authentication Model Selection — State which model is chosen (JWT stateless / session stateful / hybrid) and why, with a comparison table showing trade-offs
- Token Schema Design — Complete JWT payload schema listing every claim (
iss,sub,exp,jti,aud, etc.) with its type, purpose, and whether it goes in access or refresh token - Implementation Code — Production-ready code for the chosen pattern (login endpoint, token validation middleware, session creation), including typed signatures and docstrings
- Security Audit Results — Verification that all security attributes are correctly set: cookie flags, algorithm choice, rate limits, error message consistency, CSPRNG usage
- Token Lifecycle Diagram — ASCII flow showing the full lifecycle: login -> access/refresh issuance -> validation -> refresh rotation -> logout revocation
Related Skills
| Skill | Purpose |
|---|---|
api-gateway-patterns |
Gateway-level JWT validation, API key offloading, and authentication filtering at the edge |
spring-security-core |
Spring Boot-specific security filter chain, method security annotations, and JWT integration |
websocket-security |
Authentication patterns for WebSocket connections including token validation in handshakes |
Live References
Authoritative documentation links for authentication protocols and standards. The model follows markdown links at load time to resolve external references and inline content.