TechnicalJune 3, 2025•18 min read

Authentication at Scale: JWT vs. Sessions vs. OAuth

A deep dive into authentication patterns for modern web applications - comparing JWT tokens, session-based auth, and OAuth flows with real-world examples, security considerations, and practical implementation guidance.

AuthenticationSecurityBackendJWTOAuthSessionsFastAPIDjangoBest Practices

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:

  1. Identity Verification: Prove that users are who they claim to be
  2. Session Management: Remember authenticated users across requests
  3. Authorization: Determine what authenticated users can access
  4. Security: Protect against common attacks (CSRF, XSS, session hijacking, replay attacks)
  5. Scalability: Handle thousands or millions of users efficiently
  6. 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

code
1. 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)

python
from 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

code
1. 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

code
Header.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)

python
from 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)

code
1. 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)

python
from 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

AspectSessionsJWTOAuth/OIDC
StateStateful (server storage)StatelessStateless (after token exchange)
ScalabilityRequires shared session storeExcellentExcellent
RevocationInstantDifficult (needs workarounds)Depends on implementation
Mobile-friendlyPoor (cookies problematic)ExcellentExcellent
MicroservicesChallengingExcellentExcellent
SecurityMature, well-understoodComplex (XSS, secret mgmt)Outsourced to provider
ImplementationSimpleModerateComplex
User ExperienceSeamlessSeamlessOne-click (provider dependency)
Setup CostLowLowModerate to High
Runtime CostStorage costsMinimalAPI 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

  1. No silver bullet: Each pattern has specific strengths and weaknesses

  2. Start simple: Use sessions for MVPs unless you have specific requirements for JWT/OAuth

  3. Security is non-negotiable: HTTPS, secure cookies, strong passwords, rate limiting

  4. Plan for revocation: Even with JWT, you need a way to invalidate compromised tokens

  5. Hybrid approaches work: Combine patterns to get best of both worlds

  6. Don't roll your own crypto: Use established libraries (PyJWT, Authlib, Passlib)

  7. Monitor everything: Log auth events, watch for suspicious patterns

  8. 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.

VE

Veigar Elí Grétarsson

Full-Stack Developer based in Reykjavik, Iceland