Introduction
Authentication is one of those problems that seems simple until you actually implement it at scale. Pick the wrong pattern, and you'll either compromise security, create performance bottlenecks, or paint yourself into an architectural corner that's expensive to escape.
I've implemented authentication systems multiple times across different projects - from simple session-based auth for internal tools to JWT-based APIs serving mobile apps, to full OAuth integrations for B2B SaaS platforms. Each approach has specific strengths, critical weaknesses, and scenarios where it shines or fails spectacularly.
In this post, I'll break down the three main authentication patterns: session-based authentication, JWT tokens, and OAuth flows. We'll look at when to use each one, how to implement them securely, and what the real-world tradeoffs are beyond the theoretical debates.
What We're Solving
Before diving into solutions, let's clarify what authentication actually needs to accomplish:
- Identity Verification: Prove that users are who they claim to be
- Session Management: Remember authenticated users across requests
- Authorization: Determine what authenticated users can access
- Security: Protect against common attacks (CSRF, XSS, session hijacking, replay attacks)
- Scalability: Handle thousands or millions of users efficiently
- User Experience: Fast, reliable, and invisible when working correctly
Different authentication patterns excel at different aspects of this problem.
Pattern 1: Session-Based Authentication
Session-based auth is the traditional, time-tested approach. The server stores session state, the client gets a session ID cookie.
How It Works
code1. User submits credentials (username/password) 2. Server verifies credentials against database 3. Server creates session record in session store 4. Server returns session ID as HTTP-only cookie 5. Client includes cookie automatically on subsequent requests 6. Server looks up session in store to verify authentication
Implementation Example (FastAPI)
pythonfrom fastapi import FastAPI, Depends, HTTPException, Response from fastapi.security import HTTPBasic, HTTPBasicCredentials from starlette.middleware.sessions import SessionMiddleware import secrets import redis import json app = FastAPI() app.add_middleware(SessionMiddleware, secret_key="your-secret-key-here") # Redis for session storage redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True) class SessionManager: def __init__(self, redis_client): self.redis = redis_client self.session_expiry = 3600 * 24 * 7 # 7 days def create_session(self, user_id: int, user_data: dict) -> str: session_id = secrets.token_urlsafe(32) session_data = { "user_id": user_id, "user_data": user_data, "created_at": time.time() } self.redis.setex( f"session:{session_id}", self.session_expiry, json.dumps(session_data) ) return session_id def get_session(self, session_id: str) -> dict | None: data = self.redis.get(f"session:{session_id}") return json.loads(data) if data else None def delete_session(self, session_id: str): self.redis.delete(f"session:{session_id}") def refresh_session(self, session_id: str): self.redis.expire(f"session:{session_id}", self.session_expiry) session_manager = SessionManager(redis_client) @app.post("/login") async def login(response: Response, username: str, password: str): # Verify credentials (simplified) user = verify_user_credentials(username, password) if not user: raise HTTPException(status_code=401, detail="Invalid credentials") # Create session session_id = session_manager.create_session( user_id=user.id, user_data={"username": user.username, "email": user.email} ) # Set HTTP-only cookie response.set_cookie( key="session_id", value=session_id, httponly=True, secure=True, # HTTPS only samesite="lax", max_age=session_manager.session_expiry ) return {"message": "Login successful"} async def get_current_user(request: Request): session_id = request.cookies.get("session_id") if not session_id: raise HTTPException(status_code=401, detail="Not authenticated") session_data = session_manager.get_session(session_id) if not session_data: raise HTTPException(status_code=401, detail="Session expired") # Refresh session expiry on activity session_manager.refresh_session(session_id) return session_data @app.get("/protected") async def protected_route(user = Depends(get_current_user)): return {"message": f"Hello, {user['user_data']['username']}"} @app.post("/logout") async def logout(response: Response, request: Request): session_id = request.cookies.get("session_id") if session_id: session_manager.delete_session(session_id) response.delete_cookie("session_id") return {"message": "Logged out"}
When to Use Sessions
✅ Great For:
- Traditional web applications with server-side rendering
- Internal dashboards and admin panels
- Applications where all traffic flows through your backend
- When you need to instantly invalidate access (logout, security incidents)
- Monolithic architectures with centralized session storage
❌ Not Ideal For:
- Mobile applications (cookies are awkward)
- Microservices architectures (shared session state is complex)
- Third-party API consumers
- Cross-domain authentication
- Serverless/edge computing environments
Pros and Cons
Advantages:
- Instant revocation: Delete the session, user is logged out everywhere immediately
- Server control: Full control over session lifecycle
- Smaller payload: Only session ID travels with each request
- Easy to audit: Session store is single source of truth
- Battle-tested: Decades of production use
Disadvantages:
- Stateful: Requires session storage infrastructure (Redis, PostgreSQL)
- Horizontal scaling complexity: All servers need access to session store
- CSRF vulnerability: Requires CSRF tokens for protection
- Cookie limitations: Same-origin policy, mobile app challenges
- Storage costs: Sessions accumulate; requires cleanup jobs
Security Considerations
python# Essential session security patterns # 1. Regenerate session ID after login (prevents session fixation) def regenerate_session(old_session_id: str, user_data: dict) -> str: session_manager.delete_session(old_session_id) return session_manager.create_session(user_data) # 2. Include IP address and User-Agent in session validation def create_secure_session(user_id: int, request: Request) -> str: session_data = { "user_id": user_id, "ip_address": request.client.host, "user_agent": request.headers.get("user-agent"), "created_at": time.time() } return session_manager.create_session(user_id, session_data) def validate_session(session_id: str, request: Request) -> bool: session = session_manager.get_session(session_id) if not session: return False # Validate request comes from same IP/browser if session["ip_address"] != request.client.host: # Consider this suspicious - potentially log and invalidate return False return True # 3. Implement absolute session timeout def check_session_age(session_data: dict, max_age_hours: int = 24): age = time.time() - session_data["created_at"] if age > (max_age_hours * 3600): raise HTTPException(status_code=401, detail="Session expired")
Pattern 2: JWT (JSON Web Tokens)
JWTs are self-contained tokens that carry all necessary information about the user. No server-side storage required.
How It Works
code1. User submits credentials 2. Server verifies credentials 3. Server creates JWT containing user claims (id, email, roles) 4. Server signs JWT with secret key 5. Client stores JWT (localStorage, memory, secure cookie) 6. Client includes JWT in Authorization header 7. Server verifies signature and extracts claims
Anatomy of a JWT
codeHeader.Payload.Signature # Header (base64url encoded) { "alg": "HS256", "typ": "JWT" } # Payload (base64url encoded) { "sub": "1234567890", # Subject (user ID) "email": "[email protected]", "role": "admin", "iat": 1516239022, # Issued at "exp": 1516242622 # Expiration } # Signature HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret_key )
Implementation Example (FastAPI + JWT)
pythonfrom fastapi import FastAPI, Depends, HTTPException, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from jose import JWTError, jwt from datetime import datetime, timedelta from pydantic import BaseModel app = FastAPI() security = HTTPBearer() # Configuration SECRET_KEY = "your-secret-key-keep-this-safe" ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 REFRESH_TOKEN_EXPIRE_DAYS = 7 class TokenData(BaseModel): user_id: int email: str role: str class TokenPair(BaseModel): access_token: str refresh_token: str token_type: str = "bearer" def create_access_token(data: dict, expires_delta: timedelta = None): to_encode = data.copy() expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15)) to_encode.update({ "exp": expire, "iat": datetime.utcnow(), "type": "access" }) return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) def create_refresh_token(data: dict): to_encode = data.copy() expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) to_encode.update({ "exp": expire, "iat": datetime.utcnow(), "type": "refresh" }) return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) def verify_token(token: str, expected_type: str = "access") -> TokenData: try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) # Verify token type if payload.get("type") != expected_type: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type" ) # Extract user data user_id: int = payload.get("sub") email: str = payload.get("email") role: str = payload.get("role") if user_id is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload" ) return TokenData(user_id=user_id, email=email, role=role) except JWTError as e: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) async def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security) ) -> TokenData: return verify_token(credentials.credentials, expected_type="access") @app.post("/login", response_model=TokenPair) async def login(username: str, password: str): # Verify credentials user = verify_user_credentials(username, password) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password" ) # Create token payload token_data = { "sub": str(user.id), "email": user.email, "role": user.role } # Generate tokens access_token = create_access_token( token_data, expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) ) refresh_token = create_refresh_token(token_data) return TokenPair( access_token=access_token, refresh_token=refresh_token ) @app.post("/refresh", response_model=TokenPair) async def refresh_token(refresh_token: str): # Verify refresh token token_data = verify_token(refresh_token, expected_type="refresh") # Generate new tokens new_token_data = { "sub": str(token_data.user_id), "email": token_data.email, "role": token_data.role } access_token = create_access_token(new_token_data) new_refresh_token = create_refresh_token(new_token_data) return TokenPair( access_token=access_token, refresh_token=new_refresh_token ) @app.get("/protected") async def protected_route(current_user: TokenData = Depends(get_current_user)): return { "message": f"Hello, {current_user.email}", "user_id": current_user.user_id, "role": current_user.role } # Permission-based access control def require_role(required_role: str): async def role_checker(user: TokenData = Depends(get_current_user)): if user.role != required_role: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires {required_role} role" ) return user return role_checker @app.delete("/admin/users/{user_id}") async def delete_user( user_id: int, admin: TokenData = Depends(require_role("admin")) ): # Admin-only endpoint return {"message": f"User {user_id} deleted by {admin.email}"}
When to Use JWT
✅ Great For:
- RESTful APIs consumed by mobile apps
- Microservices architectures (no shared state)
- Single Sign-On (SSO) across multiple services
- Third-party API integrations
- Serverless and edge computing
- Cross-domain authentication
❌ Not Ideal For:
- Applications requiring instant logout/revocation
- Long-lived sessions (refresh token management complexity)
- When payload size matters (large permission sets)
- Internal tools where session storage isn't a concern
Pros and Cons
Advantages:
- Stateless: No server-side storage needed
- Scalable: Any server can verify without database lookup
- Cross-domain: Works across different domains/services
- Mobile-friendly: Easy to include in HTTP headers
- Self-contained: All info in the token itself
Disadvantages:
- Can't revoke: Valid tokens work until expiration
- Larger payload: Every request includes full token
- Secret management: Leaked secret = all tokens compromised
- No built-in refresh: Needs refresh token implementation
- Security complexity: XSS attacks can steal tokens from localStorage
The JWT Revocation Problem
JWTs can't be invalidated before expiration. Here are common workarounds:
python# Solution 1: Short-lived access tokens + refresh tokens # Access token: 15-30 minutes # Refresh token: 7-30 days # User logout = blacklist refresh token only redis_client = redis.Redis() class TokenBlacklist: def add_to_blacklist(self, token: str, expires_in: int): """Add refresh token to blacklist""" redis_client.setex(f"blacklist:{token}", expires_in, "1") def is_blacklisted(self, token: str) -> bool: return redis_client.exists(f"blacklist:{token}") > 0 @app.post("/logout") async def logout(refresh_token: str): # Verify token first token_data = verify_token(refresh_token, expected_type="refresh") # Add to blacklist blacklist = TokenBlacklist() blacklist.add_to_blacklist( refresh_token, expires_in=REFRESH_TOKEN_EXPIRE_DAYS * 86400 ) return {"message": "Logged out successfully"} # Solution 2: Token versioning (per-user) # Store token version in database, increment on logout/password change class User(BaseModel): id: int email: str token_version: int # Increment on logout def create_access_token_with_version(user: User): data = { "sub": str(user.id), "email": user.email, "token_version": user.token_version } return create_access_token(data) async def get_current_user_with_version( credentials: HTTPAuthorizationCredentials = Depends(security) ) -> User: token_data = verify_token(credentials.credentials) # Check token version against database user = get_user_from_db(token_data.user_id) token_version = jwt.decode( credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM] ).get("token_version") if token_version != user.token_version: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Token has been revoked" ) return user
JWT Security Best Practices
python# 1. Use strong signing algorithms ALGORITHM = "RS256" # Asymmetric (public/private key) is more secure than HS256 # 2. Include standard claims def create_secure_token(user: User, request: Request): now = datetime.utcnow() return jwt.encode({ "sub": str(user.id), # Subject (user identifier) "iss": "your-app.com", # Issuer "aud": "your-api.com", # Audience "iat": now, # Issued at "exp": now + timedelta(minutes=30), # Expiration "nbf": now, # Not before "jti": secrets.token_urlsafe(16), # JWT ID (unique) }, PRIVATE_KEY, algorithm="RS256") # 3. Validate all claims def verify_secure_token(token: str): try: payload = jwt.decode( token, PUBLIC_KEY, algorithms=["RS256"], audience="your-api.com", issuer="your-app.com" ) return payload except jwt.ExpiredSignatureError: raise HTTPException(status_code=401, detail="Token expired") except jwt.InvalidAudienceError: raise HTTPException(status_code=401, detail="Invalid audience") except jwt.InvalidIssuerError: raise HTTPException(status_code=401, detail="Invalid issuer") # 4. Don't store sensitive data in JWT # Bad: {"password": "...", "ssn": "..."} # Good: {"user_id": 123, "role": "user"} # 5. Implement token rotation # Exchange old refresh token for new access + refresh tokens
Pattern 3: OAuth 2.0 / OpenID Connect
OAuth isn't authentication itself - it's an authorization framework. OpenID Connect (OIDC) adds authentication on top of OAuth 2.0.
How It Works (Authorization Code Flow)
code1. User clicks "Login with Google/GitHub/etc." 2. App redirects to OAuth provider with client_id 3. User authenticates with provider 4. Provider redirects back with authorization code 5. App exchanges code for access token (server-side) 6. App uses access token to fetch user info 7. App creates local session or JWT for the user
Implementation Example (FastAPI + Google OAuth)
pythonfrom fastapi import FastAPI, Request, HTTPException from fastapi.responses import RedirectResponse from authlib.integrations.starlette_client import OAuth from starlette.middleware.sessions import SessionMiddleware import secrets app = FastAPI() app.add_middleware(SessionMiddleware, secret_key=secrets.token_urlsafe(32)) # OAuth configuration oauth = OAuth() oauth.register( name='google', client_id='YOUR_GOOGLE_CLIENT_ID', client_secret='YOUR_GOOGLE_CLIENT_SECRET', server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', client_kwargs={'scope': 'openid email profile'} ) oauth.register( name='github', client_id='YOUR_GITHUB_CLIENT_ID', client_secret='YOUR_GITHUB_CLIENT_SECRET', access_token_url='https://github.com/login/oauth/access_token', authorize_url='https://github.com/login/oauth/authorize', api_base_url='https://api.github.com/', client_kwargs={'scope': 'user:email'} ) @app.get("/login/google") async def login_google(request: Request): redirect_uri = request.url_for('auth_google') return await oauth.google.authorize_redirect(request, redirect_uri) @app.get("/auth/google") async def auth_google(request: Request): try: token = await oauth.google.authorize_access_token(request) except Exception as e: raise HTTPException(status_code=400, detail=str(e)) # Get user info from Google user_info = token.get('userinfo') if not user_info: user_info = await oauth.google.userinfo(token=token) # Create or update user in your database user = get_or_create_user( email=user_info['email'], name=user_info['name'], oauth_provider='google', oauth_id=user_info['sub'] ) # Create session or JWT session_id = session_manager.create_session(user.id, { "email": user.email, "name": user.name }) # Redirect to app with session response = RedirectResponse(url='/dashboard') response.set_cookie( key="session_id", value=session_id, httponly=True, secure=True, samesite="lax" ) return response @app.get("/login/github") async def login_github(request: Request): redirect_uri = request.url_for('auth_github') return await oauth.github.authorize_redirect(request, redirect_uri) @app.get("/auth/github") async def auth_github(request: Request): token = await oauth.github.authorize_access_token(request) # Fetch user info resp = await oauth.github.get('user', token=token) user_info = resp.json() # Get email (GitHub requires separate call) email_resp = await oauth.github.get('user/emails', token=token) emails = email_resp.json() primary_email = next( (e['email'] for e in emails if e['primary']), user_info.get('email') ) user = get_or_create_user( email=primary_email, name=user_info['name'], oauth_provider='github', oauth_id=str(user_info['id']) ) # Create session session_id = session_manager.create_session(user.id, { "email": user.email, "name": user.name }) response = RedirectResponse(url='/dashboard') response.set_cookie( key="session_id", value=session_id, httponly=True, secure=True ) return response # Hybrid approach: OAuth + JWT @app.get("/auth/google/mobile") async def auth_google_mobile(request: Request): """For mobile apps, return JWT instead of setting cookie""" token = await oauth.google.authorize_access_token(request) user_info = token.get('userinfo') user = get_or_create_user( email=user_info['email'], name=user_info['name'], oauth_provider='google', oauth_id=user_info['sub'] ) # Return JWT for mobile app access_token = create_access_token({ "sub": str(user.id), "email": user.email }) refresh_token = create_refresh_token({ "sub": str(user.id), "email": user.email }) return { "access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer" }
When to Use OAuth
✅ Great For:
- Consumer applications where users have Google/GitHub/etc. accounts
- B2B SaaS requiring enterprise SSO (SAML, Azure AD, Okta)
- Delegated access (e.g., "Allow this app to access your Google Drive")
- Reducing friction - no password to remember
- Compliance requirements (outsource auth to certified providers)
❌ Not Ideal For:
- Internal tools (setup overhead not worth it)
- Applications requiring custom authentication logic
- Offline-first applications
- When you need full control over auth flow
Pros and Cons
Advantages:
- Security: Auth handled by specialized providers
- User experience: One-click login, no password to remember
- Compliance: Providers handle GDPR, SOC2, etc.
- Features: MFA, account recovery, etc. included
- Reduced liability: Don't store passwords
Disadvantages:
- Dependency: If provider is down, so is your login
- User data: Limited control over user identity
- Complexity: Multiple flows, token exchange, error handling
- Vendor lock-in: Migrating providers is painful
- Cost: Enterprise SSO providers charge per user
Comparison Table
| Aspect | Sessions | JWT | OAuth/OIDC |
|---|---|---|---|
| State | Stateful (server storage) | Stateless | Stateless (after token exchange) |
| Scalability | Requires shared session store | Excellent | Excellent |
| Revocation | Instant | Difficult (needs workarounds) | Depends on implementation |
| Mobile-friendly | Poor (cookies problematic) | Excellent | Excellent |
| Microservices | Challenging | Excellent | Excellent |
| Security | Mature, well-understood | Complex (XSS, secret mgmt) | Outsourced to provider |
| Implementation | Simple | Moderate | Complex |
| User Experience | Seamless | Seamless | One-click (provider dependency) |
| Setup Cost | Low | Low | Moderate to High |
| Runtime Cost | Storage costs | Minimal | API calls to provider |
Hybrid Approaches (What I Actually Use)
In reality, most production systems combine these patterns:
Approach 1: Sessions + OAuth
python# Use OAuth for login, sessions for session management # Best for web apps with social login @app.get("/auth/google") async def auth_google(request: Request): # Get user from OAuth user = authenticate_with_google(request) # Create traditional session session_id = session_manager.create_session(user.id, user_data) # Return session cookie response.set_cookie("session_id", session_id, httponly=True) return response
Benefits: Instant revocation + social login convenience
Approach 2: JWT + Refresh Tokens
python# Short-lived JWT access tokens (15 min) # Long-lived refresh tokens in HTTP-only cookies # Best for mobile apps and SPAs @app.post("/login") async def login(response: Response, credentials: LoginData): user = verify_credentials(credentials) # Short access token access_token = create_access_token(user, expires=timedelta(minutes=15)) # Long refresh token in secure cookie refresh_token = create_refresh_token(user, expires=timedelta(days=30)) response.set_cookie( "refresh_token", refresh_token, httponly=True, secure=True, samesite="strict" ) return {"access_token": access_token}
Benefits: Stateless performance + reasonable revocation (30-min window max)
Approach 3: Multi-Provider SSO + JWT
python# OAuth for external users, JWT for API access # Best for B2B SaaS platforms @app.get("/auth/{provider}") async def unified_auth(provider: str, request: Request): # Support multiple SSO providers if provider == "google": user = await authenticate_google(request) elif provider == "azure": user = await authenticate_azure_ad(request) elif provider == "okta": user = await authenticate_okta(request) else: raise HTTPException(400, "Unsupported provider") # Return JWT for API access access_token = create_access_token(user) return {"access_token": access_token}
Benefits: Enterprise SSO + API flexibility
Real-World Decision Framework
Here's how I decide which pattern to use:
Choose Sessions When:
- Building traditional web apps (server-rendered or minimal SPA)
- Instant logout is critical (banking, healthcare, admin panels)
- All clients are browsers
- You already have Redis/PostgreSQL
- Team is more comfortable with traditional auth
Choose JWT When:
- Building APIs for mobile apps
- Microservices architecture
- Third-party API consumers
- Serverless/edge compute environment
- Cross-domain authentication required
Choose OAuth When:
- Target users already have Google/GitHub/Microsoft accounts
- Enterprise SSO required (Okta, Azure AD)
- Don't want to handle passwords
- Compliance requirements favor outsourced auth
- Building consumer-facing apps
Combine Them When:
- Building a full platform (web + mobile + API)
- Need both convenience (OAuth) and control (sessions/JWT)
- Supporting multiple client types
- Growing from MVP to scale (start simple, add complexity as needed)
Security Checklist
Regardless of which pattern you choose:
Essential Security Measures:
python# 1. Always use HTTPS in production # HTTP is fundamentally insecure for auth # 2. Implement rate limiting from slowapi import Limiter limiter = Limiter(key_func=get_remote_address) @app.post("/login") @limiter.limit("5/minute") # 5 attempts per minute async def login(request: Request, credentials: LoginData): pass # 3. Hash passwords properly from passlib.context import CryptContext pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") def hash_password(password: str) -> str: return pwd_context.hash(password) def verify_password(plain: str, hashed: str) -> bool: return pwd_context.verify(plain, hashed) # 4. Implement CSRF protection for cookie-based auth from starlette_csrf import CSRFMiddleware app.add_middleware(CSRFMiddleware, secret="your-csrf-secret") # 5. Set secure cookie flags response.set_cookie( key="session_id", value=session_id, httponly=True, # Prevents JavaScript access secure=True, # HTTPS only samesite="lax", # CSRF protection domain=".yourapp.com", # Scope appropriately max_age=3600 ) # 6. Validate JWT signatures # NEVER trust unsigned JWTs or skip signature verification # 7. Log authentication events import logging logger.info(f"Login attempt: user={email}, ip={ip}, success={success}") # 8. Implement MFA for sensitive operations # Use TOTP (Google Authenticator) or SMS for critical actions # 9. Monitor for suspicious activity # - Multiple failed login attempts # - Logins from unusual locations # - Token reuse patterns # - Concurrent sessions from different IPs # 10. Have an incident response plan # - How to invalidate all sessions/tokens # - How to notify affected users # - How to audit what data was accessed
Migration Strategies
Changing auth systems in production is risky. Here's how to do it safely:
Sessions → JWT Migration
python# Support both during transition period async def get_current_user_hybrid(request: Request): # Try JWT first auth_header = request.headers.get("authorization") if auth_header and auth_header.startswith("Bearer "): token = auth_header.replace("Bearer ", "") try: return verify_token(token) except: pass # Fall back to session session_id = request.cookies.get("session_id") if session_id: return get_session_user(session_id) raise HTTPException(401, "Not authenticated") # Gradually migrate users over weeks/months
Adding OAuth to Existing System
python# Allow both traditional login and OAuth class User: id: int email: str password_hash: Optional[str] # None if OAuth-only oauth_provider: Optional[str] oauth_id: Optional[str] @app.post("/login/traditional") async def login_traditional(credentials: LoginData): # Check password exists user = get_user(credentials.email) if not user.password_hash: raise HTTPException( 400, "This account uses social login. Please use 'Login with Google'" ) # Proceed with password verification # Allow linking accounts @app.post("/link/google") async def link_google_account( user: User = Depends(get_current_user), oauth_token: str = None ): # Verify OAuth token, link to existing account pass
Performance Considerations
Sessions Performance
python# Use connection pooling for Redis import redis.asyncio as redis redis_pool = redis.ConnectionPool( host='localhost', port=6379, max_connections=50, decode_responses=True ) redis_client = redis.Redis(connection_pool=redis_pool) # Use pipelining for batch operations async def verify_multiple_sessions(session_ids: list[str]): async with redis_client.pipeline() as pipe: for sid in session_ids: pipe.get(f"session:{sid}") return await pipe.execute() # Cache frequently accessed data # Don't hit database on every request
JWT Performance
python# Cache public keys (for RS256) from functools import lru_cache @lru_cache(maxsize=1) def get_public_key(): return load_public_key_from_file() # Use faster algorithms for signing # RS256 is slower than HS256 but more secure # For internal services, HS256 may be acceptable # Keep payload small # Large JWTs slow down every request # Bad: Include entire user object (1KB+) # Good: Include minimal claims (user_id, role) (100 bytes)
Key Takeaways
-
No silver bullet: Each pattern has specific strengths and weaknesses
-
Start simple: Use sessions for MVPs unless you have specific requirements for JWT/OAuth
-
Security is non-negotiable: HTTPS, secure cookies, strong passwords, rate limiting
-
Plan for revocation: Even with JWT, you need a way to invalidate compromised tokens
-
Hybrid approaches work: Combine patterns to get best of both worlds
-
Don't roll your own crypto: Use established libraries (PyJWT, Authlib, Passlib)
-
Monitor everything: Log auth events, watch for suspicious patterns
-
Have a breach plan: Know how to invalidate all sessions/tokens if needed
Conclusion
Authentication is too critical to get wrong, but also too important to over-engineer. For most startups and new projects:
Start here: Sessions with Redis + traditional login + password hashing Add next: OAuth for social login (Google, GitHub) Scale with: Move to JWT when you add mobile apps or microservices Enterprise: Add SAML/SSO when B2B customers demand it
The best authentication system is one that's secure, maintainable, and appropriate for your current scale. You can always evolve it as your requirements grow.
Focus on getting the basics right: HTTPS everywhere, secure cookies, proper password hashing, rate limiting, and monitoring. These fundamentals matter more than which specific pattern you choose.
Now stop overthinking it and start building.
Have questions about implementing authentication for your specific use case? Reach out - I've made enough mistakes in this area to help you avoid them.