JWT vs Sessions: When to Use Each for Authentication

TL;DR

Sessions are simpler and more secure for traditional web apps. JWTs work better for APIs and microservices. Sessions require server state, JWTs don't. Pick based on your architecture, not hype.

I rebuilt our authentication system three times. First with sessions because "that's what everyone does." Then with JWTs because "sessions don't scale." Then back to sessions because "JWTs have security issues."

Each time I thought I was fixing problems. Each time I created new ones. The truth is both approaches work, but for different use cases. The debate isn't about which is better - it's about which fits your architecture.

Here's everything you need to know about JWTs vs sessions, with real code, benchmarks, and a decision framework.

How Sessions Work

Server stores session data, gives client a session ID:

// Login with sessions
app.post('/login', async (req, res) => {
    const user = await db.query(
        'SELECT * FROM users WHERE email = ? AND password = ?',
        [req.body.email, hashPassword(req.body.password)]
    );

    if (!user) {
        return res.status(401).json({ error: 'Invalid credentials' });
    }

    // Create session
    req.session.userId = user.id;
    req.session.email = user.email;

    res.json({ success: true, user: { id: user.id, email: user.email } });
});

// Protected route
app.get('/api/profile', (req, res) => {
    if (!req.session.userId) {
        return res.status(401).json({ error: 'Not authenticated' });
    }

    // Session data is available
    res.json({
        userId: req.session.userId,
        email: req.session.email
    });
});

// Logout
app.post('/logout', (req, res) => {
    req.session.destroy();
    res.json({ success: true });
});

What happens:

  1. User logs in with credentials
  2. Server creates session, stores in Redis/database
  3. Server sends session ID as cookie
  4. Client sends cookie with each request
  5. Server looks up session by ID

Session storage (Redis):

session:abc123 → { userId: 42, email: "user@example.com", createdAt: ... }
session:def456 → { userId: 89, email: "other@example.com", createdAt: ... }

How JWTs Work

Server signs token with user data, client stores it:

const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET;

// Login with JWT
app.post('/login', async (req, res) => {
    const user = await db.query(
        'SELECT * FROM users WHERE email = ? AND password = ?',
        [req.body.email, hashPassword(req.body.password)]
    );

    if (!user) {
        return res.status(401).json({ error: 'Invalid credentials' });
    }

    // Create JWT
    const token = jwt.sign(
        { userId: user.id, email: user.email },
        JWT_SECRET,
        { expiresIn: '7d' }
    );

    res.json({ token, user: { id: user.id, email: user.email } });
});

// Protected route
app.get('/api/profile', (req, res) => {
    const token = req.headers.authorization?.split(' ')[1];

    if (!token) {
        return res.status(401).json({ error: 'No token provided' });
    }

    try {
        const decoded = jwt.verify(token, JWT_SECRET);

        res.json({
            userId: decoded.userId,
            email: decoded.email
        });
    } catch (error) {
        res.status(401).json({ error: 'Invalid token' });
    }
});

// Logout (client-side only)
// Client deletes the token
// Server can't invalidate JWT (without additional complexity)

What happens:

  1. User logs in with credentials
  2. Server creates JWT with user data, signs it
  3. Client stores JWT (localStorage, memory, cookie)
  4. Client sends JWT with each request
  5. Server verifies signature, extracts user data

No server storage - everything is in the token.

Performance Benchmarks

I tested both on the same server handling 10,000 requests:

Sessions (Redis)

// Session middleware
app.use(session({
    store: new RedisStore({ client: redis }),
    secret: SESSION_SECRET,
    resave: false,
    saveUninitialized: false
}));

// Benchmark: 10,000 authenticated requests
// Average response time: 12ms
// Redis calls: 10,000 (1 per request)
// Memory usage: Constant (sessions in Redis)

JWT

// JWT middleware
app.use((req, res, next) => {
    const token = req.headers.authorization?.split(' ')[1];
    if (token) {
        try {
            req.user = jwt.verify(token, JWT_SECRET);
        } catch (error) {
            // Invalid token
        }
    }
    next();
});

// Benchmark: 10,000 authenticated requests
// Average response time: 8ms
// Redis calls: 0
// Memory usage: Increases with concurrent requests

Results:

  • JWT is 33% faster (no Redis lookup)
  • JWT uses more memory (decode on each request)
  • Sessions require Redis/database
  • JWTs work without external state

Security Comparison

Sessions Win: Instant Revocation

// Sessions: Revoke immediately
app.post('/logout', (req, res) => {
    req.session.destroy(); // Session deleted from Redis
    res.json({ success: true });
});

// Ban user immediately
await redis.del(`session:${sessionId}`);
// User is logged out on next request

// JWT: Can't revoke without workaround
app.post('/logout', (req, res) => {
    // Token still valid until expiration!
    // Need blacklist or other mechanism
    res.json({ success: true });
});

Problem with JWT: Once issued, valid until expiration. Logout requires additional infrastructure:

// JWT revocation with Redis blacklist
const blacklist = new Set();

// Logout - add to blacklist
app.post('/logout', (req, res) => {
    const token = req.headers.authorization?.split(' ')[1];
    const decoded = jwt.verify(token, JWT_SECRET);

    // Add to blacklist until expiration
    const ttl = decoded.exp - Math.floor(Date.now() / 1000);
    redis.setex(`blacklist:${token}`, ttl, '1');

    res.json({ success: true });
});

// Check blacklist on every request
app.use(async (req, res, next) => {
    const token = req.headers.authorization?.split(' ')[1];

    if (token) {
        const blacklisted = await redis.get(`blacklist:${token}`);
        if (blacklisted) {
            return res.status(401).json({ error: 'Token revoked' });
        }
    }

    next();
});

// Now you need Redis anyway - lost JWT's main benefit

Sessions Win: Sensitive Data Changes

// Sessions: Update role immediately
await db.query('UPDATE users SET role = ? WHERE id = ?', ['admin', userId]);

// Next request sees new role (loaded from database)
app.get('/admin', async (req, res) => {
    const user = await db.query('SELECT * FROM users WHERE id = ?', [req.session.userId]);

    if (user.role !== 'admin') {
        return res.status(403).json({ error: 'Forbidden' });
    }

    // Admin action
});

// JWT: Role stays in token until expiration
const token = jwt.sign({ userId: 42, role: 'user' }, SECRET);
// User gets promoted to admin
// Token still says role: 'user' for up to 7 days!

Workaround: Short-lived JWTs (15 min) + refresh tokens. But now you're back to server state.

JWTs Win: XSS Prevention (if done right)

// Bad: JWT in localStorage
localStorage.setItem('token', jwt);
// XSS can steal: localStorage.getItem('token')

// Bad: JWT in sessionStorage
sessionStorage.setItem('token', jwt);
// XSS can steal: sessionStorage.getItem('token')

// Good: JWT in httpOnly cookie
res.cookie('token', jwt, {
    httpOnly: true,  // JavaScript can't access
    secure: true,    // HTTPS only
    sameSite: 'strict'
});

// But now it's basically a session ID cookie
// And you need CSRF protection

Reality: Sessions in httpOnly cookies are just as secure.

Both Need: CSRF Protection

// CSRF attack
// Malicious site makes request with your cookies
<img src="https://yourbank.com/transfer?to=attacker&amount=1000">

// Protection: CSRF token
app.use(csrf());

app.get('/form', (req, res) => {
    res.render('form', { csrfToken: req.csrfToken() });
});

app.post('/action', (req, res) => {
    // Validates CSRF token automatically
    // Works for both sessions and JWT-in-cookies
});

The JWT Payload Size Problem

JWTs get large with more data:

// Minimal JWT
const token = jwt.sign({ userId: 123 }, SECRET);
// Size: ~150 bytes

// With more data
const token = jwt.sign({
    userId: 123,
    email: 'user@example.com',
    name: 'John Doe',
    role: 'admin',
    permissions: ['read', 'write', 'delete'],
    orgId: 456,
    metadata: { ... }
}, SECRET);
// Size: ~600 bytes

// Sent with every request
// 10,000 requests = 6MB of JWT overhead
// vs session: 10,000 * 32 bytes = 320KB

Problem: JWT sent with every request. Sessions only send 32-byte ID.

When Sessions Work Better

Traditional Web Applications

// Server-rendered pages with forms
app.get('/login', (req, res) => {
    res.render('login');
});

app.post('/login', async (req, res) => {
    const user = await authenticateUser(req.body);
    req.session.userId = user.id;
    res.redirect('/dashboard');
});

app.get('/dashboard', requireAuth, (req, res) => {
    res.render('dashboard', { user: req.session });
});

// Sessions are perfect here
// - Browser handles cookies automatically
// - No JavaScript needed
// - Instant logout
// - Can update session data anytime

Single-Server Applications

// In-memory sessions (small apps)
const sessions = new Map();

app.post('/login', (req, res) => {
    const sessionId = generateId();
    sessions.set(sessionId, { userId: user.id });

    res.cookie('sessionId', sessionId, { httpOnly: true });
    res.json({ success: true });
});

// No external dependencies
// Perfect for side projects and MVPs

When You Need Instant Revocation

// Ban user immediately
app.post('/admin/ban-user/:userId', async (req, res) => {
    await db.query('UPDATE users SET banned = true WHERE id = ?', [req.params.userId]);

    // Delete all their sessions
    const sessions = await redis.keys(`session:*`);
    for (const key of sessions) {
        const session = await redis.get(key);
        if (JSON.parse(session).userId === req.params.userId) {
            await redis.del(key);
        }
    }

    res.json({ success: true });
    // User logged out on next request
});

When JWTs Work Better

APIs Consumed by Mobile Apps

// Mobile app login
async function login(email, password) {
    const response = await fetch('https://api.example.com/login', {
        method: 'POST',
        body: JSON.stringify({ email, password })
    });

    const { token } = await response.json();

    // Store locally
    await SecureStore.setItemAsync('token', token);
}

// API requests
async function getProfile() {
    const token = await SecureStore.getItemAsync('token');

    return fetch('https://api.example.com/profile', {
        headers: { Authorization: `Bearer ${token}` }
    });
}

// Mobile apps don't use cookies well
// JWT in Authorization header works perfectly

Microservices Architecture

// API Gateway validates JWT once
app.use(async (req, res, next) => {
    const token = req.headers.authorization?.split(' ')[1];

    if (token) {
        req.user = jwt.verify(token, JWT_SECRET);
    }

    next();
});

// Forwards to microservices
app.get('/api/posts', (req, res) => {
    // Forward request to posts service with user info
    const response = await fetch('http://posts-service/posts', {
        headers: { 'X-User-Id': req.user.userId }
    });

    res.json(await response.json());
});

// Each microservice can verify JWT independently
// No shared session store needed

Third-Party API Access

// Issue JWT for API access
app.post('/api/tokens', requireAuth, async (req, res) => {
    const token = jwt.sign(
        {
            userId: req.user.id,
            scope: req.body.scope,
            appId: req.body.appId
        },
        API_SECRET,
        { expiresIn: '30d' }
    );

    await db.query('INSERT INTO api_tokens SET ?', {
        user_id: req.user.id,
        token_hash: hashToken(token),
        scope: req.body.scope
    });

    res.json({ token });
});

// Third-party uses token for API access
// No session management needed

The Hybrid Approach

Use both for different purposes:

// Sessions for web authentication
app.use(session({
    store: new RedisStore({ client: redis }),
    secret: SESSION_SECRET
}));

// JWT for API authentication
const authenticateAPI = (req, res, next) => {
    const token = req.headers.authorization?.split(' ')[1];

    if (token) {
        try {
            req.user = jwt.verify(token, JWT_SECRET);
            return next();
        } catch (error) {
            return res.status(401).json({ error: 'Invalid token' });
        }
    }

    res.status(401).json({ error: 'No token provided' });
};

// Web routes use sessions
app.get('/dashboard', requireSession, (req, res) => {
    res.render('dashboard', { user: req.session });
});

// API routes use JWT
app.get('/api/posts', authenticateAPI, (req, res) => {
    res.json({ posts: [], userId: req.user.userId });
});

Refresh Tokens: Best of Both Worlds

Short-lived JWT + long-lived refresh token:

// Login returns both tokens
app.post('/login', async (req, res) => {
    const user = await authenticateUser(req.body);

    // Short-lived access token (15 minutes)
    const accessToken = jwt.sign(
        { userId: user.id, email: user.email },
        JWT_SECRET,
        { expiresIn: '15m' }
    );

    // Long-lived refresh token (7 days)
    const refreshToken = generateSecureToken();

    // Store refresh token in database
    await db.query('INSERT INTO refresh_tokens SET ?', {
        user_id: user.id,
        token: hashToken(refreshToken),
        expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
    });

    res.json({ accessToken, refreshToken });
});

// Refresh access token
app.post('/refresh', async (req, res) => {
    const { refreshToken } = req.body;

    // Verify refresh token in database
    const stored = await db.query(
        'SELECT * FROM refresh_tokens WHERE token = ? AND expires_at > NOW()',
        [hashToken(refreshToken)]
    );

    if (!stored) {
        return res.status(401).json({ error: 'Invalid refresh token' });
    }

    // Issue new access token
    const accessToken = jwt.sign(
        { userId: stored.user_id },
        JWT_SECRET,
        { expiresIn: '15m' }
    );

    res.json({ accessToken });
});

// Logout: Delete refresh token
app.post('/logout', async (req, res) => {
    const { refreshToken } = req.body;

    await db.query('DELETE FROM refresh_tokens WHERE token = ?', [
        hashToken(refreshToken)
    ]);

    res.json({ success: true });
});

Benefits:

  • Short-lived JWT (15 min) limits exposure
  • Can revoke by deleting refresh token
  • No database lookup on every request
  • Best security + performance tradeoff

Production Implementation

Complete session-based auth:

const session = require('express-session');
const RedisStore = require('connect-redis').default;
const Redis = require('ioredis');

const redis = new Redis({
    host: process.env.REDIS_HOST,
    port: 6379,
    password: process.env.REDIS_PASSWORD
});

app.use(session({
    store: new RedisStore({ client: redis }),
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: {
        secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
        httpOnly: true,
        maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
        sameSite: 'lax'
    }
}));

// CSRF protection
const csrf = require('csurf');
app.use(csrf());

// Login
app.post('/login', async (req, res) => {
    const user = await db.query(
        'SELECT * FROM users WHERE email = ?',
        [req.body.email]
    );

    const validPassword = await bcrypt.compare(
        req.body.password,
        user.password_hash
    );

    if (!validPassword) {
        return res.status(401).json({ error: 'Invalid credentials' });
    }

    req.session.userId = user.id;
    req.session.email = user.email;

    res.json({
        success: true,
        user: { id: user.id, email: user.email }
    });
});

// Auth middleware
const requireAuth = (req, res, next) => {
    if (!req.session.userId) {
        return res.status(401).json({ error: 'Not authenticated' });
    }
    next();
};

// Protected routes
app.get('/api/profile', requireAuth, async (req, res) => {
    const user = await db.query(
        'SELECT id, email, name FROM users WHERE id = ?',
        [req.session.userId]
    );

    res.json(user);
});

// Logout
app.post('/logout', (req, res) => {
    req.session.destroy((err) => {
        if (err) {
            return res.status(500).json({ error: 'Logout failed' });
        }
        res.json({ success: true });
    });
});

Complete JWT-based auth:

const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET;

// Login
app.post('/login', async (req, res) => {
    const user = await db.query(
        'SELECT * FROM users WHERE email = ?',
        [req.body.email]
    );

    const validPassword = await bcrypt.compare(
        req.body.password,
        user.password_hash
    );

    if (!validPassword) {
        return res.status(401).json({ error: 'Invalid credentials' });
    }

    const accessToken = jwt.sign(
        { userId: user.id, email: user.email },
        JWT_SECRET,
        { expiresIn: '15m' }
    );

    const refreshToken = crypto.randomBytes(40).toString('hex');

    await db.query('INSERT INTO refresh_tokens SET ?', {
        user_id: user.id,
        token: await bcrypt.hash(refreshToken, 10),
        expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
    });

    res.json({
        accessToken,
        refreshToken,
        user: { id: user.id, email: user.email }
    });
});

// Auth middleware
const requireAuth = (req, res, next) => {
    const token = req.headers.authorization?.split(' ')[1];

    if (!token) {
        return res.status(401).json({ error: 'No token provided' });
    }

    try {
        req.user = jwt.verify(token, JWT_SECRET);
        next();
    } catch (error) {
        res.status(401).json({ error: 'Invalid token' });
    }
};

// Protected routes
app.get('/api/profile', requireAuth, async (req, res) => {
    const user = await db.query(
        'SELECT id, email, name FROM users WHERE id = ?',
        [req.user.userId]
    );

    res.json(user);
});

// Refresh
app.post('/refresh', async (req, res) => {
    const { refreshToken } = req.body;

    const tokens = await db.query(
        'SELECT * FROM refresh_tokens WHERE expires_at > NOW()'
    );

    let validToken = null;
    for (const token of tokens) {
        if (await bcrypt.compare(refreshToken, token.token)) {
            validToken = token;
            break;
        }
    }

    if (!validToken) {
        return res.status(401).json({ error: 'Invalid refresh token' });
    }

    const accessToken = jwt.sign(
        { userId: validToken.user_id },
        JWT_SECRET,
        { expiresIn: '15m' }
    );

    res.json({ accessToken });
});

// Logout
app.post('/logout', async (req, res) => {
    const { refreshToken } = req.body;

    // Delete refresh token
    const tokens = await db.query('SELECT * FROM refresh_tokens');

    for (const token of tokens) {
        if (await bcrypt.compare(refreshToken, token.token)) {
            await db.query('DELETE FROM refresh_tokens WHERE id = ?', [token.id]);
            break;
        }
    }

    res.json({ success: true });
});

My Decision Framework

Use Sessions when:

  • Traditional server-rendered web app
  • Need instant logout/revocation
  • Single server or can use Redis
  • Sensitive data that changes frequently
  • Simpler implementation preferred

Use JWTs when:

  • Mobile app or SPA
  • Microservices architecture
  • Third-party API access
  • Can't use cookies (CORS issues)
  • Stateless authentication needed

Use Both when:

  • Web app + mobile app
  • Different auth for web vs API
  • Need flexibility

Use Refresh Tokens when:

  • Want JWT benefits with revocation
  • Security is critical
  • Can handle extra complexity

Common Mistakes

Mistake 1: Storing JWTs in localStorage

// Bad - XSS can steal token
localStorage.setItem('token', jwt);

// Good - httpOnly cookie
res.cookie('token', jwt, { httpOnly: true, secure: true });

Mistake 2: Long-lived JWTs without refresh

// Bad - can't revoke for 30 days
const token = jwt.sign({ userId }, SECRET, { expiresIn: '30d' });

// Good - short-lived with refresh token
const token = jwt.sign({ userId }, SECRET, { expiresIn: '15m' });

Mistake 3: Putting sensitive data in JWT

// Bad - JWT is not encrypted
const token = jwt.sign({
    userId,
    ssn: user.ssn,
    creditCard: user.cc
}, SECRET);

// Good - only IDs and non-sensitive data
const token = jwt.sign({ userId }, SECRET);

The Bottom Line

Both sessions and JWTs work. Pick based on architecture:

Sessions are simpler for traditional web apps. Built-in security, instant revocation, easy to implement.

JWTs are better for APIs consumed by mobile apps or microservices. Stateless, scalable, no shared session store.

Don't cargo-cult. Just because big companies use JWTs doesn't mean you need them.

Start with sessions. They're simpler. Migrate to JWTs only when you have a specific reason.

I wasted months migrating between auth systems. Both work fine when used correctly. The problem was using the wrong tool for my architecture.

Pick the one that fits your app. Implement it securely. Move on to solving actual problems.