Uncategorized

OAuth2, JWT, and Session Tokens Explained with Diagrams

Oauth2 Jwt Sesion Tokens 683x1024

Introduction

Authentication can be confusing—especially when buzzwords like OAuth2, JWT, and session tokens get thrown around interchangeably. These three concepts serve different purposes, yet they’re often mixed up even by experienced developers. This comprehensive guide breaks down each approach with clear diagrams, code examples, security considerations, and real-world implementation patterns so you’ll finally understand how modern authentication actually works and when to use each approach.

The Fundamental Problem

Before diving into tokens and protocols, let’s understand what we’re trying to solve:

“How can a server know that a user is who they claim to be, and remember them across multiple requests?”

HTTP is stateless—each request is independent. The server has no built-in way to remember that you logged in five seconds ago. The solution is to give authenticated users a token—a temporary proof of identity. How that token is issued, validated, stored, and secured depends on whether you’re using sessions, JWTs, or OAuth2.

Session Tokens: The Classic Approach

Session tokens are the traditional way to manage user authentication, and they remain the most secure option for server-rendered web applications.

How Session Authentication Works

┌─────────────┐                              ┌─────────────┐
│   Browser   │                              │   Server    │
└──────┬──────┘                              └──────┬──────┘
       │                                            │
       │ 1. POST /login {email, password}           │
       │───────────────────────────────────────────>│
       │                                            │
       │                    2. Validate credentials │
       │                    3. Create session in DB │
       │                    4. Generate session ID  │
       │                                            │
       │ 5. Set-Cookie: sessionId=abc123; HttpOnly  │
       │<───────────────────────────────────────────│
       │                                            │
       │ 6. GET /dashboard (Cookie: sessionId=abc123)│
       │───────────────────────────────────────────>│
       │                                            │
       │                    7. Lookup session in DB │
       │                    8. Load user data       │
       │                                            │
       │ 9. 200 OK (Dashboard HTML)                 │
       │<───────────────────────────────────────────│
       │                                            │

Implementation Example (Node.js/Express)

// server.js - Session-based authentication
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const bcrypt = require('bcrypt');

const app = express();
app.use(express.json());

// Redis client for session storage
const redisClient = createClient({
  url: process.env.REDIS_URL
});
redisClient.connect();

// Session configuration
app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  name: 'sessionId', // Custom cookie name (not 'connect.sid')
  cookie: {
    secure: process.env.NODE_ENV === 'production', // HTTPS only in production
    httpOnly: true,      // Prevents JavaScript access
    maxAge: 24 * 60 * 60 * 1000, // 24 hours
    sameSite: 'strict',  // CSRF protection
  }
}));

// Login endpoint
app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  
  try {
    // Find user in database
    const user = await User.findOne({ email });
    if (!user) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }
    
    // Verify password
    const isValid = await bcrypt.compare(password, user.passwordHash);
    if (!isValid) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }
    
    // Regenerate session to prevent fixation attacks
    req.session.regenerate((err) => {
      if (err) {
        return res.status(500).json({ error: 'Session error' });
      }
      
      // Store user info in session
      req.session.userId = user.id;
      req.session.email = user.email;
      req.session.role = user.role;
      
      req.session.save((err) => {
        if (err) {
          return res.status(500).json({ error: 'Session save error' });
        }
        res.json({ message: 'Login successful', user: { id: user.id, email: user.email } });
      });
    });
  } catch (error) {
    res.status(500).json({ error: 'Server error' });
  }
});

// Authentication middleware
function requireAuth(req, res, next) {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Authentication required' });
  }
  next();
}

// Protected route
app.get('/dashboard', requireAuth, async (req, res) => {
  const user = await User.findById(req.session.userId);
  res.json({ dashboard: 'Welcome!', user });
});

// Logout endpoint
app.post('/logout', (req, res) => {
  req.session.destroy((err) => {
    if (err) {
      return res.status(500).json({ error: 'Logout failed' });
    }
    res.clearCookie('sessionId');
    res.json({ message: 'Logged out successfully' });
  });
});

Session Storage Options

Storage Pros Cons Best For
Memory Fast, simple Lost on restart, no scaling Development only
Redis Fast, scalable, TTL support Extra infrastructure Production apps
PostgreSQL Already have it, ACID Slower than Redis Smaller apps
MongoDB Document-based, flexible Less performant MongoDB-based apps

Session Token Pros and Cons

Pros:

  • Server controls everything—easy to revoke sessions instantly
  • Secure when properly configured (HttpOnly, Secure, SameSite)
  • No sensitive data exposed to client
  • Built-in CSRF protection with SameSite cookies

Cons:

  • Requires shared session storage for horizontal scaling
  • Not ideal for mobile apps or SPAs calling APIs
  • Additional infrastructure (Redis) for production

JWT (JSON Web Tokens): Stateless Authentication

JWT is a self-contained token that carries the user's identity and claims. Unlike session tokens, the server doesn't need to store anything—everything is in the token itself.

JWT Structure

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

┌─────────────────────────────────────────────────────────────┐
│ HEADER (Algorithm & Type)                                    │
│ {"alg": "HS256", "typ": "JWT"}                              │
├─────────────────────────────────────────────────────────────┤
│ PAYLOAD (Claims)                                             │
│ {                                                            │
│   "sub": "1234567890",     // Subject (user ID)             │
│   "name": "John Doe",      // Custom claim                  │
│   "email": "john@example.com",                              │
│   "role": "admin",         // Authorization data            │
│   "iat": 1516239022,       // Issued at                     │
│   "exp": 1516242622        // Expiration time               │
│ }                                                            │
├─────────────────────────────────────────────────────────────┤
│ SIGNATURE                                                    │
│ HMACSHA256(base64(header) + "." + base64(payload), secret)  │
└─────────────────────────────────────────────────────────────┘

How JWT Authentication Works

┌─────────────┐                              ┌─────────────┐
│   Client    │                              │   Server    │
└──────┬──────┘                              └──────┬──────┘
       │                                            │
       │ 1. POST /login {email, password}           │
       │───────────────────────────────────────────>│
       │                                            │
       │                    2. Validate credentials │
       │                    3. Create JWT with claims│
       │                    4. Sign JWT with secret │
       │                                            │
       │ 5. {accessToken: "eyJ...", refreshToken}   │
       │<───────────────────────────────────────────│
       │                                            │
       │ 6. Store tokens (memory/localStorage)      │
       │                                            │
       │ 7. GET /api/data                           │
       │    Authorization: Bearer eyJ...            │
       │───────────────────────────────────────────>│
       │                                            │
       │                    8. Verify JWT signature │
       │                    9. Check expiration     │
       │                    10. Extract user claims │
       │                                            │
       │ 11. 200 OK {data}                          │
       │<───────────────────────────────────────────│

Implementation Example (Node.js)

// jwt-auth.js - JWT-based authentication
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');

const app = express();
app.use(express.json());

const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';

// In-memory refresh token store (use Redis in production)
const refreshTokens = new Set();

// Generate tokens
function generateTokens(user) {
  const payload = {
    sub: user.id,
    email: user.email,
    role: user.role,
  };
  
  const accessToken = jwt.sign(payload, ACCESS_TOKEN_SECRET, {
    expiresIn: ACCESS_TOKEN_EXPIRY,
    issuer: 'your-app',
    audience: 'your-app-users',
  });
  
  const refreshToken = jwt.sign(
    { sub: user.id, type: 'refresh' },
    REFRESH_TOKEN_SECRET,
    { expiresIn: REFRESH_TOKEN_EXPIRY }
  );
  
  return { accessToken, refreshToken };
}

// Login endpoint
app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  
  try {
    const user = await User.findOne({ email });
    if (!user || !await bcrypt.compare(password, user.passwordHash)) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }
    
    const { accessToken, refreshToken } = generateTokens(user);
    
    // Store refresh token (for revocation capability)
    refreshTokens.add(refreshToken);
    
    // Send refresh token as HttpOnly cookie
    res.cookie('refreshToken', refreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
    });
    
    res.json({
      accessToken,
      expiresIn: 900, // 15 minutes in seconds
      user: { id: user.id, email: user.email, role: user.role },
    });
  } catch (error) {
    res.status(500).json({ error: 'Server error' });
  }
});

// JWT verification middleware
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
  
  if (!token) {
    return res.status(401).json({ error: 'Access token required' });
  }
  
  try {
    const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET, {
      issuer: 'your-app',
      audience: 'your-app-users',
    });
    req.user = decoded;
    next();
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
    }
    return res.status(403).json({ error: 'Invalid token' });
  }
}

// Role-based authorization middleware
function requireRole(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  };
}

// Refresh token endpoint
app.post('/refresh', (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  
  if (!refreshToken) {
    return res.status(401).json({ error: 'Refresh token required' });
  }
  
  if (!refreshTokens.has(refreshToken)) {
    return res.status(403).json({ error: 'Invalid refresh token' });
  }
  
  try {
    const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);
    const user = await User.findById(decoded.sub);
    
    if (!user) {
      return res.status(403).json({ error: 'User not found' });
    }
    
    // Generate new access token
    const accessToken = jwt.sign(
      { sub: user.id, email: user.email, role: user.role },
      ACCESS_TOKEN_SECRET,
      { expiresIn: ACCESS_TOKEN_EXPIRY }
    );
    
    res.json({ accessToken, expiresIn: 900 });
  } catch (error) {
    return res.status(403).json({ error: 'Invalid refresh token' });
  }
});

// Protected route
app.get('/api/profile', authenticateToken, (req, res) => {
  res.json({ user: req.user });
});

// Admin-only route
app.delete('/api/users/:id', authenticateToken, requireRole('admin'), (req, res) => {
  // Delete user logic
  res.json({ message: 'User deleted' });
});

// Logout (revoke refresh token)
app.post('/logout', (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  refreshTokens.delete(refreshToken);
  res.clearCookie('refreshToken');
  res.json({ message: 'Logged out' });
});

Frontend Token Management

// auth-service.ts - Frontend JWT handling
class AuthService {
  private accessToken: string | null = null;
  private tokenExpiresAt: number = 0;

  async login(email: string, password: string): Promise {
    const response = await fetch('/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
      credentials: 'include', // Include cookies for refresh token
    });

    if (!response.ok) {
      throw new Error('Login failed');
    }

    const data = await response.json();
    this.setAccessToken(data.accessToken, data.expiresIn);
    return data.user;
  }

  private setAccessToken(token: string, expiresIn: number): void {
    this.accessToken = token;
    // Set expiry 1 minute before actual expiry for safety
    this.tokenExpiresAt = Date.now() + (expiresIn - 60) * 1000;
  }

  async getAccessToken(): Promise {
    // Check if token is expired or about to expire
    if (!this.accessToken || Date.now() >= this.tokenExpiresAt) {
      await this.refreshAccessToken();
    }
    return this.accessToken;
  }

  private async refreshAccessToken(): Promise {
    try {
      const response = await fetch('/refresh', {
        method: 'POST',
        credentials: 'include', // Send refresh token cookie
      });

      if (!response.ok) {
        this.logout();
        throw new Error('Session expired');
      }

      const data = await response.json();
      this.setAccessToken(data.accessToken, data.expiresIn);
    } catch (error) {
      this.logout();
      throw error;
    }
  }

  async fetchWithAuth(url: string, options: RequestInit = {}): Promise {
    const token = await this.getAccessToken();
    
    return fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': `Bearer ${token}`,
      },
    });
  }

  logout(): void {
    this.accessToken = null;
    this.tokenExpiresAt = 0;
    fetch('/logout', { method: 'POST', credentials: 'include' });
  }
}

export const authService = new AuthService();

JWT Pros and Cons

Pros:

  • Stateless—no server-side storage needed
  • Scalable across multiple servers without shared state
  • Perfect for APIs, mobile apps, and microservices
  • Self-contained—contains all user info needed

Cons:

  • Cannot be revoked without a blacklist
  • Larger than session IDs (adds to request size)
  • Payload is readable (base64, not encrypted)
  • Requires careful security considerations

OAuth2: Delegated Authorization

OAuth2 is NOT an authentication protocol—it's an authorization framework. It allows third-party applications to access user resources without getting their password.

OAuth2 Roles

┌────────────────────────────────────────────────────────────┐
│                     OAuth2 Participants                     │
├────────────────────────────────────────────────────────────┤
│                                                            │
│  Resource Owner: The user who owns the data                │
│  (You, wanting to use "Login with Google")                 │
│                                                            │
│  Client: Your application requesting access                │
│  (Your app wanting to access user's Google profile)        │
│                                                            │
│  Authorization Server: Issues tokens after user consent    │
│  (Google's OAuth server at accounts.google.com)            │
│                                                            │
│  Resource Server: Hosts the protected resources            │
│  (Google's API servers with user data)                     │
│                                                            │
└────────────────────────────────────────────────────────────┘

Authorization Code Flow (Most Secure)

┌─────────┐     ┌─────────┐     ┌─────────────────┐     ┌─────────────┐
│  User   │     │  Client │     │ Auth Server     │     │ Resource    │
│ Browser │     │   App   │     │ (Google)        │     │ Server      │
└────┬────┘     └────┬────┘     └────────┬────────┘     └──────┬──────┘
     │               │                   │                     │
     │ 1. Click      │                   │                     │
     │ "Login with   │                   │                     │
     │  Google"      │                   │                     │
     │──────────────>│                   │                     │
     │               │                   │                     │
     │               │ 2. Redirect to    │                     │
     │               │    Google OAuth   │                     │
     │<──────────────│                   │                     │
     │               │                   │                     │
     │ 3. Login to Google & Grant Access │                     │
     │──────────────────────────────────>│                     │
     │               │                   │                     │
     │ 4. Redirect with authorization code                     │
     │<──────────────────────────────────│                     │
     │               │                   │                     │
     │ 5. Send code  │                   │                     │
     │──────────────>│                   │                     │
     │               │                   │                     │
     │               │ 6. Exchange code  │                     │
     │               │    for tokens     │                     │
     │               │──────────────────>│                     │
     │               │                   │                     │
     │               │ 7. Access token + │                     │
     │               │    refresh token  │                     │
     │               │<──────────────────│                     │
     │               │                   │                     │
     │               │ 8. Request user data with access token  │
     │               │─────────────────────────────────────────>│
     │               │                   │                     │
     │               │ 9. Return user data                     │
     │               │<─────────────────────────────────────────│
     │               │                   │                     │
     │ 10. Logged in!│                   │                     │
     │<──────────────│                   │                     │

Implementation: "Login with Google"

// oauth-google.js - Google OAuth2 implementation
const express = require('express');
const { OAuth2Client } = require('google-auth-library');
const jwt = require('jsonwebtoken');

const app = express();

const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
const REDIRECT_URI = 'http://localhost:3000/auth/google/callback';

const oauth2Client = new OAuth2Client(
  GOOGLE_CLIENT_ID,
  GOOGLE_CLIENT_SECRET,
  REDIRECT_URI
);

// Step 1: Redirect to Google
app.get('/auth/google', (req, res) => {
  const state = generateRandomState(); // CSRF protection
  req.session.oauthState = state;
  
  const authUrl = oauth2Client.generateAuthUrl({
    access_type: 'offline',       // Get refresh token
    scope: [
      'https://www.googleapis.com/auth/userinfo.profile',
      'https://www.googleapis.com/auth/userinfo.email',
    ],
    state: state,
    prompt: 'consent',            // Force consent screen
  });
  
  res.redirect(authUrl);
});

// Step 2: Handle callback
app.get('/auth/google/callback', async (req, res) => {
  const { code, state } = req.query;
  
  // Verify state (CSRF protection)
  if (state !== req.session.oauthState) {
    return res.status(403).json({ error: 'Invalid state parameter' });
  }
  
  try {
    // Exchange code for tokens
    const { tokens } = await oauth2Client.getToken(code);
    oauth2Client.setCredentials(tokens);
    
    // Get user info from Google
    const userInfoResponse = await fetch(
      'https://www.googleapis.com/oauth2/v2/userinfo',
      {
        headers: { Authorization: `Bearer ${tokens.access_token}` },
      }
    );
    const googleUser = await userInfoResponse.json();
    
    // Find or create user in your database
    let user = await User.findOne({ googleId: googleUser.id });
    
    if (!user) {
      user = await User.create({
        googleId: googleUser.id,
        email: googleUser.email,
        name: googleUser.name,
        picture: googleUser.picture,
        provider: 'google',
      });
    }
    
    // Create your own session/JWT
    const accessToken = jwt.sign(
      { sub: user.id, email: user.email },
      process.env.JWT_SECRET,
      { expiresIn: '15m' }
    );
    
    // Redirect to frontend with token
    res.redirect(`http://localhost:3001/auth/callback?token=${accessToken}`);
  } catch (error) {
    console.error('OAuth error:', error);
    res.redirect('http://localhost:3001/login?error=oauth_failed');
  }
});

// Helper function
function generateRandomState() {
  return require('crypto').randomBytes(32).toString('hex');
}

OAuth2 with PKCE (for SPAs and Mobile)

PKCE (Proof Key for Code Exchange) adds security for public clients that can't keep secrets:

// oauth-pkce.ts - Frontend PKCE implementation
class OAuthPKCE {
  private codeVerifier: string = '';

  // Generate code verifier and challenge
  async generatePKCE(): Promise<{ verifier: string; challenge: string }> {
    // Generate random code verifier
    const array = new Uint8Array(32);
    crypto.getRandomValues(array);
    this.codeVerifier = this.base64URLEncode(array);
    
    // Generate code challenge (SHA256 hash of verifier)
    const encoder = new TextEncoder();
    const data = encoder.encode(this.codeVerifier);
    const hash = await crypto.subtle.digest('SHA-256', data);
    const challenge = this.base64URLEncode(new Uint8Array(hash));
    
    return { verifier: this.codeVerifier, challenge };
  }

  private base64URLEncode(buffer: Uint8Array): string {
    return btoa(String.fromCharCode(...buffer))
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '');
  }

  async startOAuthFlow(): Promise {
    const { challenge } = await this.generatePKCE();
    const state = crypto.randomUUID();
    
    // Store state and verifier
    sessionStorage.setItem('oauth_state', state);
    sessionStorage.setItem('code_verifier', this.codeVerifier);
    
    const params = new URLSearchParams({
      client_id: 'YOUR_CLIENT_ID',
      redirect_uri: 'http://localhost:3001/callback',
      response_type: 'code',
      scope: 'openid profile email',
      state: state,
      code_challenge: challenge,
      code_challenge_method: 'S256',
    });
    
    window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
  }

  async handleCallback(code: string, state: string): Promise {
    // Verify state
    const savedState = sessionStorage.getItem('oauth_state');
    if (state !== savedState) {
      throw new Error('Invalid state');
    }
    
    const codeVerifier = sessionStorage.getItem('code_verifier');
    
    // Exchange code for token
    const response = await fetch('/auth/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        code,
        code_verifier: codeVerifier,
      }),
    });
    
    const { access_token } = await response.json();
    
    // Cleanup
    sessionStorage.removeItem('oauth_state');
    sessionStorage.removeItem('code_verifier');
    
    return access_token;
  }
}

OpenID Connect (OIDC) - OAuth2 + Identity

OAuth2 handles authorization, but for authentication, use OpenID Connect (OIDC), which adds an identity layer:

// OIDC adds an ID Token to the OAuth2 response
{
  "access_token": "ya29.xxx...",     // For accessing APIs
  "id_token": "eyJhbGciOiJSUzI1...", // JWT with user identity
  "refresh_token": "1//xxx...",
  "token_type": "Bearer",
  "expires_in": 3600
}

// The ID Token contains identity claims:
{
  "iss": "https://accounts.google.com",  // Issuer
  "sub": "110169484474386276334",         // Unique user ID
  "aud": "YOUR_CLIENT_ID",               // Your app
  "email": "user@gmail.com",
  "email_verified": true,
  "name": "John Doe",
  "picture": "https://...",
  "iat": 1516239022,
  "exp": 1516242622
}

Complete Comparison

Feature Session Tokens JWT OAuth2
Server Storage Required Not required Not required
Scalability Needs shared storage Excellent Excellent
Revocation Instant Requires blacklist Provider-dependent
Token Size Small (~32 bytes) Large (1KB+) Varies
Self-contained No Yes Access token: varies
Best For Traditional web apps APIs, Mobile, SPAs Third-party login
Security Model Cookie-based Bearer token Delegated access
Complexity Low Medium High

Common Mistakes to Avoid

Mistake 1: Storing JWTs in localStorage

// WRONG - Vulnerable to XSS attacks
localStorage.setItem('token', accessToken);

// BETTER - Store access token in memory only
class TokenStore {
  private token: string | null = null;
  
  setToken(token: string) { this.token = token; }
  getToken() { return this.token; }
}

// BEST - Use HttpOnly cookie for refresh token
// Access token in memory, refreshed via HttpOnly cookie

Mistake 2: Long-Lived Access Tokens

// WRONG - 7-day access token
jwt.sign(payload, secret, { expiresIn: '7d' });

// CORRECT - Short-lived access token + refresh token
const accessToken = jwt.sign(payload, secret, { expiresIn: '15m' });
const refreshToken = jwt.sign({ sub: userId }, refreshSecret, { expiresIn: '7d' });

Mistake 3: Storing Sensitive Data in JWT Payload

// WRONG - JWT payload is readable (base64, not encrypted)
jwt.sign({
  sub: userId,
  creditCard: '4111111111111111', // NEVER DO THIS
  ssn: '123-45-6789',             // NEVER DO THIS
}, secret);

// CORRECT - Only store identifiers and roles
jwt.sign({
  sub: userId,
  email: user.email,
  role: user.role,
}, secret);

Mistake 4: Missing CSRF Protection for Sessions

// WRONG - Session cookie without CSRF protection
app.use(session({
  cookie: {
    httpOnly: true,
    // Missing sameSite!
  }
}));

// CORRECT - Use SameSite cookies
app.use(session({
  cookie: {
    httpOnly: true,
    secure: true,
    sameSite: 'strict', // or 'lax' for cross-site navigation
  }
}));

Mistake 5: Not Validating OAuth2 State Parameter

// WRONG - Ignoring state parameter
app.get('/callback', async (req, res) => {
  const { code } = req.query;
  // Proceeding without state validation - CSRF vulnerable!
});

// CORRECT - Always validate state
app.get('/callback', async (req, res) => {
  const { code, state } = req.query;
  
  if (state !== req.session.oauthState) {
    return res.status(403).json({ error: 'CSRF detected' });
  }
  // Proceed with token exchange
});

When to Use Each Approach

Scenario Recommendation
Traditional server-rendered web app Sessions with Redis
Single Page Application (SPA) JWT with refresh token rotation
Mobile application JWT with secure storage
API-first architecture JWT
Microservices JWT or OAuth2
"Login with Google/GitHub" OAuth2 + OIDC
Third-party API access OAuth2
High-security requirements Sessions + 2FA

Conclusion

Modern authentication comes down to understanding your use case and choosing the right approach:

  • Use sessions for traditional web applications where you control the entire stack and need easy revocation
  • Use JWTs for stateless APIs, mobile apps, and microservices where scalability matters
  • Use OAuth2 when delegating access to third-party services or implementing "Login with..." functionality

Each approach has trade-offs. Sessions offer simplicity and control. JWTs provide scalability and self-contained claims. OAuth2 enables secure third-party integration. Understanding these differences helps you build secure, scalable authentication systems.

For implementation guides, check out our Spring Security JWT Authentication tutorial and Securing CI/CD Pipelines. For official documentation, see the OAuth 2.0 specification and JWT.io introduction.