[Redis Beyond Caching] Part 4: Single Session Enforcement with Redis

How to implement “one device at a time” login using Redis overwrite semantics—solving the JWT revocation problem without complex token blacklists.

🔐 The Problem: JWT’s Stateless Trade-off

The Stateless Advantage

JWT (JSON Web Token) is popular because it’s stateless:

  • No server-side session storage needed
  • Horizontally scalable—any server can validate the token
  • Self-contained—user info embedded in the token

The Revocation Problem

But stateless comes with a critical downside:

JWT’s revocation problem is not about impossibility, but about the loss of statelessness once revocation is required.

You can revoke JWTs using blacklists, token versions, or jti claims with a store—but all of these reintroduce server-side state, defeating the original purpose.

Consequences of staying purely stateless:

  • ❌ Can’t force logout a user
  • ❌ Can’t invalidate stolen tokens immediately
  • ❌ Can’t implement “one device at a time” restriction

Business Requirement: Account Security

Our system originally used pure stateless JWT—simple, scalable, and horizontally friendly. But then product requirements changed:

“We need to limit users to one active session at a time to prevent account sharing.”

For high-security applications (trading, banking, subscriptions), you need:

  • Single session enforcement — Only one device can be logged in at a time
  • Device kick — New login automatically logs out the previous device
  • Immediate revocation — Compromised accounts can be locked instantly

This meant we had to intentionally sacrifice statelessness to regain session control.


🔧 The Solution: Redis Overwrite Strategy

Core Concept

Instead of tracking all tokens (blacklist), we track only the current valid token per user:

1
2
Key:   session:{userId}
Value: hash(currentToken)

When a new login occurs, we simply overwrite the old value. The previous token becomes invalid because it no longer matches what’s stored.

⚠️ This is a Hybrid Auth Model: JWT acts as a bearer credential, but Redis is the authoritative source of session validity—not the JWT itself. We intentionally reintroduce server-side state to regain control over sessions.

Data Model

1
2
3
4
5
6
// On login: store hash of the new token
await redis.set(`session:${userId}`, hashToken(newJwt), 'EX', TOKEN_TTL);

// On request: verify token matches stored hash
const storedHash = await redis.get(`session:${userId}`);
const isValid = storedHash === hashToken(requestToken);

💡 We store a hash of the token, not the token itself. This protects token confidentiality if Redis is read-compromised, but does not prevent session forgery if Redis integrity (write access) is compromised.


💻 Implementation

Login Flow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { createHash } from 'crypto';
import Redis from 'ioredis';
import jwt from 'jsonwebtoken';

const redis = new Redis();
const TOKEN_TTL = 86400; // 24 hours

function hashToken(token: string): string {
  return createHash('sha256').update(token).digest('hex');
}

async function login(userId: string, credentials: any) {
  // 1. Validate credentials
  const user = await validateCredentials(credentials);
  if (!user) throw new Error('Invalid credentials');
  
  // 2. Generate new JWT
  const token = jwt.sign({ userId, email: user.email }, SECRET, {
    expiresIn: '24h',
  });
  
  // 3. Store hash in Redis (overwrites any previous session)
  await redis.set(`session:${userId}`, hashToken(token), 'EX', TOKEN_TTL);
  
  return { token };
}

The Kick-out Flow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Device A logs in:
  → Token_A generated
  → Redis: SET session:user123 = hash(Token_A)

Device B logs in (same user):
  → Token_B generated
  → Redis: SET session:user123 = hash(Token_B)  ← overwrites!

Device A makes request:
  → Middleware checks: hash(Token_A) === stored hash?
  → stored hash = hash(Token_B)
  → hash(Token_A) ≠ hash(Token_B)
  → Return 401 Unauthorized → Device A forced logout

Auth Middleware

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
async function authMiddleware(req, res, next) {
  const token = req.headers.authorization?.replace('Bearer ', '');
  
  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }
  
  try {
    // 1. Verify JWT signature and expiration
    const decoded = jwt.verify(token, SECRET);
    
    // 2. Check if this token is still the "current" one in Redis
    // Note: Redis is the source of truth, not the JWT
    const storedHash = await redis.get(`session:${decoded.userId}`);
    
    if (!storedHash || storedHash !== hashToken(token)) {
      return res.status(401).json({ 
        error: 'Session expired', 
        code: 'SESSION_SUPERSEDED' 
      });
    }
    
    req.user = decoded;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid token' });
  }
}

Explicit Logout

1
2
3
async function logout(userId: string) {
  await redis.del(`session:${userId}`);
}

⚠️ Production Caveats

Token Refresh Handling

If you use refresh tokens, decide:

  • Option A: Refresh token also gets single-session treatment
  • Option B: Only access token is tracked; refresh creates new session
1
2
3
4
5
6
7
// Option A: Refresh also overwrites
async function refreshToken(userId: string, refreshToken: string) {
  // Validate refresh token...
  const newAccessToken = jwt.sign({ userId }, SECRET);
  await redis.set(`session:${userId}`, hashToken(newAccessToken), 'EX', TOKEN_TTL);
  return { accessToken: newAccessToken };
}

Redis Failure Mode

If Redis is down:

  • Fail-open: Allow requests (security risk)
  • Fail-closed: Deny all requests (availability risk)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
async function authMiddleware(req, res, next) {
  try {
    const storedHash = await redis.get(`session:${decoded.userId}`);
    // ... validation
  } catch (redisError) {
    // Decision: fail-open or fail-closed?
    logger.error('Redis unavailable', redisError);
    return res.status(503).json({ error: 'Service temporarily unavailable' });
  }
}

Race Condition on Concurrent Login

If two devices login at the exact same millisecond:

1
2
Device A: SET session:123 = hash_A
Device B: SET session:123 = hash_B  (concurrent)

Redis SET is atomic, so one will win. But both devices might briefly think they’re logged in until their next request.

This is usually acceptable for single-session enforcement, but if strict ordering matters, consider using WATCH/MULTI or Lua scripts.


📝 Summary

  • JWT’s weakness — Can’t revoke tokens before expiration
  • Redis overwrite — Store only the current valid token hash; new login overwrites old
  • Device kick — Automatic; no explicit “logout other devices” needed
  • Trade-off — Adds Redis dependency to auth flow; requires fail-mode decision

This pattern provides immediate session control without maintaining a growing blacklist of revoked tokens.


References

comments powered by Disqus