/ blog/beyond-jwts-stateful-session-architecture
blog / beyond-jwts-stateful-session-architecture / overview.md

Beyond JWTs: Designing a Stateful, High-Performance Session Architecture

Stateless JWTs are great until you need to instantly revoke a compromised session. Here's how to build a stateful, Redis-backed authentication system that handles 50k+ concurrent users with sub-millisecond validation.

The Stateless JWT Lie

For the last decade, the industry defaulted to stateless JSON Web Tokens (JWTs) for API authentication. The pitch is alluring: sign a token, give it to the client, and your backend never needs to hit a database to verify who the user is.

But this introduces a fatal security flaw: Stateless tokens cannot be revoked.

If a user's laptop is stolen, or a session is hijacked via XSS, that JWT remains valid until its exp (expiration) claim is reached. To mitigate this, developers make JWTs short-lived (e.g., 15 minutes) and introduce Refresh Tokens. But even a 15-minute window is a lifetime for an automated attack script.

When building our enterprise CMS, we threw out stateless JWTs entirely. Instead, we built a highly-optimized stateful session system.

The Architecture: Opaque Tokens + Redis

Instead of sending a cryptographically signed payload to the client, we send a cryptographically random string (an opaque token).

// Generating a secure opaque token in Go
func GenerateSessionToken() (string, error) {
    b := make([]byte, 32)
    if _, err := rand.Read(b); err != nil {
        return "", err
    }
    return base64.URLEncoding.EncodeToString(b), nil
}

This token is utterly meaningless on its own. It cannot be parsed or decoded.

When the client presents this token in an Authorization: Bearer <token> header or an HttpOnly cookie, the server must look it up.

The Redis Hot Path

Hitting Postgres on every single API request is a recipe for bottlenecking your application. This is why people flee to JWTs in the first place.

The solution is Redis.

// The validation middleware
async function validateSession(req, res, next) {
  const token = req.cookies.session_id;
  if (!token) return res.status(401).send("Unauthorized");

  // Hash the token before looking it up to prevent timing attacks 
  // and protect against Redis database dumps
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
  
  // O(1) lookup in Redis
  const sessionData = await redis.get(`session:${tokenHash}`);
  
  if (!sessionData) {
    return res.status(401).send("Invalid or expired session");
  }

  // Attach user to request
  req.user = JSON.parse(sessionData);
  next();
}

Because Redis stores data entirely in memory, this lookup takes < 1ms. The performance difference between verifying a JWT signature (CPU bound) and a Redis GET (Network/Memory bound) is negligible at most scales.

The Instant Revocation Superpower

Because our system is stateful, revocation is trivial and instantaneous.

When a user clicks "Sign out of all devices", or when our threat-detection system flags a suspicious IP jump, we simply delete the keys from Redis.

async function revokeUserSessions(userId: string) {
  // We keep a Redis SET of all active session hashes for a user
  const sessionHashes = await redis.smembers(`user:${userId}:sessions`);
  
  const pipeline = redis.pipeline();
  for (const hash of sessionHashes) {
    pipeline.del(`session:${hash}`);
  }
  pipeline.del(`user:${userId}:sessions`);
  
  await pipeline.exec();
}

The very next request the attacker makes will fail. There is no 15-minute wait.

Protecting the Database

What if Redis goes down? Does the site go down?

In our architecture, Redis acts as an ephemeral cache for the true source of truth: PostgreSQL.

When a session is created, it is written to Postgres, and then cached in Redis. If Redis restarts and loses its data, the validation middleware simply falls back to Postgres:

let sessionData = await redis.get(`session:${tokenHash}`);

if (!sessionData) {
  // Cache miss. Check Postgres.
  const pgSession = await db.sessions.findUnique({ where: { tokenHash } });
  
  if (pgSession && pgSession.expiresAt > new Date()) {
    // Valid session. Repopulate Redis.
    await redis.setex(
      `session:${tokenHash}`, 
      TTL_SECONDS, 
      JSON.stringify(pgSession.userData)
    );
    sessionData = pgSession.userData;
  }
}

Security Considerations

  1. Hash before storing: Never store the raw opaque token in your database. Store a SHA-256 hash. If your database is compromised, the attacker gets hashes, not usable tokens.
  2. HttpOnly Cookies: If your frontend and backend share a root domain, use HttpOnly, Secure, SameSite=Lax cookies. This completely immunizes your tokens against XSS extraction.
  3. Entropy: Ensure your tokens have at least 256 bits of entropy generated from a CSPRNG.

Stateful sessions aren't legacy—they are the prerequisite for building high-security applications.

Tags

securityauthbackendredis
0
0