Authentication Strategy

Session vs Token Authentication

Understand the trade-offs between session-based and token-based authentication. Choose the right approach for your application's security and scalability needs.

Authentication Fundamentals

Authentication verifies user identity. The question is: where do you store the authentication state?

Every web application needs to authenticate users. Once a user logs in, subsequent requests must prove they are who they claim to be. There are two fundamentally different approaches:

🍪 Session-Based (Stateful)

Server stores session data. Client receives a session ID cookie. Server looks up session data on every request.

Storage: Server-side (Redis, database)

🎫 Token-Based (Stateless)

Server signs a token (JWT) containing user data. Client stores token (localStorage, cookie). Server verifies signature on every request.

Storage: Client-side (browser)

Key Insight: Sessions require server-side state, tokens are stateless. This fundamental difference drives all the trade-offs discussed below.

Session-Based Authentication

The traditional approach used by monolithic web applications. The server maintains a session store (in-memory, Redis, database) that maps session IDs to user data.

How It Works

Login Flow:
1. User submits credentials (username/password)
2. Server validates credentials
3. Server creates session object: { userId: 123, email: "[email protected]", createdAt: ... }
4. Server stores session in Redis/DB with key: "session:abc123"
5. Server sends session ID as HTTP-only cookie: Set-Cookie: sessionId=abc123

Subsequent Requests:
1. Browser automatically sends cookie: Cookie: sessionId=abc123
2. Server retrieves session from store: GET session:abc123
3. Server validates session (not expired, user exists)
4. Server attaches user data to request context
5. Request proceeds

Implementation Example (Python/Flask)

from flask import Flask, request, jsonify, session
from flask_session import Session
import redis

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_REDIS'] = redis.StrictRedis(host='localhost', port=6379)
Session(app)

@app.route('/login', methods=['POST'])
def login():
    """Login endpoint - create session"""
    data = request.json
    username = data.get('username')
    password = data.get('password')
    
    # Validate credentials (check database)
    user = validate_user(username, password)  # Your validation logic
    if not user:
        return jsonify({"error": "Invalid credentials"}), 401
    
    # Create session (stored in Redis automatically by flask-session)
    session['user_id'] = user['id']
    session['email'] = user['email']
    session['role'] = user['role']
    
    return jsonify({"message": "Login successful"}), 200

@app.route('/profile')
def profile():
    """Protected route - requires session"""
    if 'user_id' not in session:
        return jsonify({"error": "Unauthorized"}), 401
    
    user_id = session['user_id']
    # Fetch user data from database
    return jsonify({"user_id": user_id, "email": session['email']})

@app.route('/logout', methods=['POST'])
def logout():
    """Logout - destroy session"""
    session.clear()  # Removes session from Redis
    return jsonify({"message": "Logged out"}), 200

def validate_user(username, password):
    # Placeholder for database validation
    return {"id": 123, "email": "[email protected]", "role": "user"}

Benefits

  • Easy Revocation: Logout is immediate—just delete the session from the store.
  • Server Control: Can update session data without client involvement (e.g., change user roles mid-session).
  • Security: Session ID in HTTP-only cookie prevents XSS attacks (JavaScript can't access it).
  • Auditing: Easy to track active sessions, concurrent logins, and user activity.

Drawbacks

  • Server-Side Storage Required: Need Redis, Memcached, or database for session store.
  • Horizontal Scaling Complexity: All servers must access the same session store (sticky sessions or shared store).
  • Database Dependency: Every authenticated request queries the session store.
  • CORS Complexity: Cookies don't work well with cross-origin requests from different domains.

Token-Based Authentication (JWT)

Modern approach favored by APIs and mobile apps. The server signs a JWT containing user data and sends it to the client. The client includes the token in every request.

What is JWT?

JSON Web Token (JWT) is a compact, self-contained token with three parts:

Header

Algorithm and token type
{"alg": "RS256", "typ": "JWT"}

Payload

User claims (ID, roles, expiration)
{"sub": "123", "exp": 1234567890}

Signature

Cryptographic signature to verify authenticity

How It Works

Login Flow:
1. User submits credentials
2. Server validates credentials
3. Server creates JWT payload: { sub: "123", email: "[email protected]", exp: 1h }
4. Server signs JWT with private key: token = sign(payload, privateKey)
5. Server sends JWT to client: { "access_token": "eyJhbGciOi..." }
6. Client stores token (localStorage, cookie, memory)

Subsequent Requests:
1. Client sends token in header: Authorization: Bearer eyJhbGciOi...
2. Server extracts token from header
3. Server verifies signature with public key
4. Server checks expiration (exp claim)
5. Server extracts user data from payload
6. Request proceeds (no database lookup needed!)

Implementation Example (Python)

import jwt
from datetime import datetime, timedelta
from flask import Flask, request, jsonify

app = Flask(__name__)
SECRET_KEY = "your-secret-key"  # Use RS256 with public/private keys in production

@app.route('/login', methods=['POST'])
def login():
    """Login endpoint - issue JWT"""
    data = request.json
    username = data.get('username')
    password = data.get('password')
    
    # Validate credentials
    user = validate_user(username, password)
    if not user:
        return jsonify({"error": "Invalid credentials"}), 401
    
    # Create JWT payload
    payload = {
        "sub": user['id'],  # Subject (user ID)
        "email": user['email'],
        "role": user['role'],
        "exp": datetime.utcnow() + timedelta(hours=1),  # Expires in 1 hour
        "iat": datetime.utcnow()  # Issued at
    }
    
    # Sign JWT
    token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
    
    return jsonify({
        "access_token": token,
        "token_type": "Bearer",
        "expires_in": 3600
    }), 200

@app.route('/profile')
def profile():
    """Protected route - requires JWT"""
    auth_header = request.headers.get('Authorization')
    if not auth_header or not auth_header.startswith('Bearer '):
        return jsonify({"error": "Missing or invalid Authorization header"}), 401
    
    token = auth_header.split(' ')[1]
    
    try:
        # Verify signature and decode payload
        payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
        user_id = payload['sub']
        email = payload['email']
        
        return jsonify({"user_id": user_id, "email": email})
    except jwt.ExpiredSignatureError:
        return jsonify({"error": "Token has expired"}), 401
    except jwt.InvalidTokenError:
        return jsonify({"error": "Invalid token"}), 401

def validate_user(username, password):
    return {"id": 123, "email": "[email protected]", "role": "user"}

Benefits

  • Stateless & Scalable: No server-side session store needed. Servers don't share state.
  • Horizontal Scaling: Any server can validate any token (just needs the public key).
  • Cross-Domain Friendly: Works seamlessly with CORS and different domains.
  • Mobile-Friendly: Perfect for native mobile apps (no cookie support needed).
  • Performance: No database lookup on every request.

Drawbacks

  • Difficult Revocation: Can't invalidate a token before expiration (need blacklist or short TTL).
  • Token Size: JWTs are larger than session IDs (100-500 bytes vs 16-32 bytes).
  • No Server Control: Can't update token claims mid-session (user must re-login).
  • Storage Security: If stored in localStorage, vulnerable to XSS. If in cookies, need CSRF protection.

Detailed Comparison

A comprehensive comparison across key dimensions to guide your authentication strategy.

Dimension Session-Based Token-Based (JWT)
State Management Stateful. Server stores session data in memory/Redis/DB. Stateless. All data encoded in the token itself.
Scalability Requires shared session store (Redis) or sticky sessions. More complex horizontal scaling. Trivially scalable. Any server can validate any token with just the public key.
Revocation Easy. Delete session from store = instant logout. Hard. Token valid until expiration. Requires blacklist or very short TTL (5-15 min) + refresh tokens.
Performance Database/cache lookup on every request. No database lookup. Signature verification only (fast).
Payload Size Small session ID (16-32 bytes) sent in cookie. Larger JWT (100-500 bytes) sent in header or cookie.
Security Session ID in HTTP-only cookie (XSS-safe). CSRF protection needed. If in localStorage: XSS vulnerable. If in HTTP-only cookie: CSRF vulnerable.
Cross-Domain Complex with cookies (SameSite restrictions, CORS preflight). Easy. Token in Authorization header works across domains.
Mobile Apps Requires custom cookie handling in native apps. Native support. Store token in secure storage (Keychain, Keystore).
Updates Mid-Session Easy. Update session data in store = immediate effect. Impossible. User must re-login to get new token with updated claims.
Infrastructure Requires Redis/Memcached/database for session store. Minimal. Just need public/private key pair for signing.

Refresh Tokens (Hybrid Approach)

The best of both worlds: short-lived access tokens + long-lived refresh tokens.

How It Works

To mitigate JWT revocation issues, use a dual-token system:

  • Access Token (JWT): Short TTL (5-15 minutes). Used for API requests. Stateless validation.
  • Refresh Token: Long TTL (days/weeks). Stored server-side. Used to issue new access tokens.
Login:
- Issue access token (exp: 15 min) + refresh token (exp: 7 days)
- Store refresh token in database with user ID

API Requests:
- Client sends access token: Authorization: Bearer \u003cshort-lived-jwt\u003e
- Server validates token (no DB lookup)

Access Token Expired:
1. Client sends refresh token to /refresh endpoint
2. Server validates refresh token (DB lookup)
3. Server issues new access token (exp: 15 min)
4. Optionally rotate refresh token for security

Logout:
- Delete refresh token from database = user must re-login
Refresh Token Implementation
import uuid
from datetime import datetime, timedelta

# Store refresh tokens in database
refresh_tokens_db = {}  # In production: use Redis or database

@app.route('/login', methods=['POST'])
def login():
    user = validate_user(request.json['username'], request.json['password'])
    if not user:
        return jsonify({"error": "Invalid credentials"}), 401
    
    # Short-lived access token (15 minutes)
    access_payload = {
        "sub": user['id'],
        "email": user['email'],
        "exp": datetime.utcnow() + timedelta(minutes=15)
    }
    access_token = jwt.encode(access_payload, SECRET_KEY, algorithm="HS256")
    
    # Long-lived refresh token (7 days)
    refresh_token_id = str(uuid.uuid4())
    refresh_tokens_db[refresh_token_id] = {
        "user_id": user['id'],
        "expires_at": datetime.utcnow() + timedelta(days=7)
    }
    
    return jsonify({
        "access_token": access_token,
        "refresh_token": refresh_token_id,
        "expires_in": 900  # 15 minutes in seconds
    })

@app.route('/refresh', methods=['POST'])
def refresh():
    """Issue new access token using refresh token"""
    refresh_token = request.json.get('refresh_token')
    
    # Validate refresh token
    token_data = refresh_tokens_db.get(refresh_token)
    if not token_data:
        return jsonify({"error": "Invalid refresh token"}), 401
    
    if datetime.utcnow() > token_data['expires_at']:
        del refresh_tokens_db[refresh_token]  # Clean up expired token
        return jsonify({"error": "Refresh token expired"}), 401
    
    # Issue new access token
    user_id = token_data['user_id']
    access_payload = {
        "sub": user_id,
        "exp": datetime.utcnow() + timedelta(minutes=15)
    }
    access_token = jwt.encode(access_payload, SECRET_KEY, algorithm="HS256")
    
    return jsonify({
        "access_token": access_token,
        "expires_in": 900
    })

@app.route('/logout', methods=['POST'])
def logout():
    """Revoke refresh token"""
    refresh_token = request.json.get('refresh_token')
    if refresh_token in refresh_tokens_db:
        del refresh_tokens_db[refresh_token]
    return jsonify({"message": "Logged out"})
Best Practice: Use refresh tokens for most applications. Combine the scalability of JWTs with the revocation control of sessions.

Decision Guide: When to Use Each

✅ Use Sessions When...
  • Building a monolithic web app (not API-first)
  • You need instant revocation (admin panels, banking)
  • Users are on a single domain (no cross-origin issues)
  • You want to update user roles/permissions mid-session
  • You already have Redis/session store infrastructure
  • Security is paramount over scalability
✅ Use Tokens When...
  • Building a REST API or microservices
  • You need to scale horizontally (stateless servers)
  • Supporting mobile apps or SPAs
  • Cross-domain authentication required
  • You want to avoid session store infrastructure
  • Performance and scalability are priorities

Hybrid Approach (Recommended)

For most modern applications, use JWT access tokens (short TTL) + refresh tokens (server-side stored):

  • Combines stateless scalability of JWTs for most requests
  • Maintains server control via refresh token revocation
  • Balances performance, security, and user experience
Anti-Pattern: Never use long-lived JWTs (hours/days) without refresh tokens. You lose revocation ability and create security risks.

Summary

  • Sessions are stateful, require server-side storage, easy to revoke, best for monolithic apps.
  • Tokens (JWT) are stateless, scalable, hard to revoke, best for APIs and microservices.
  • Sessions excel at instant revocation and mid-session updates.
  • Tokens excel at horizontal scalability and cross-domain authentication.
  • Use refresh tokens to combine the best of both approaches (short-lived access token + long-lived revocable refresh token).
  • Store JWTs in HTTP-only cookies (with CSRF protection) or Authorization header (if not on same domain).
  • For most modern apps: JWT + Refresh Token hybrid is the recommended approach.