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
- 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.
- HttpOnly Cookies: If your frontend and backend share a root domain, use
HttpOnly,Secure,SameSite=Laxcookies. This completely immunizes your tokens against XSS extraction. - 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
Related Blogs
Defending Against SSRF in Node.js Microservices
Server-Side Request Forgery is deadly. If your app fetches URLs provided by users, you are at risk. Here's how to lock down node-fetch and axios.
Refresh token rotation — how to detect theft in a single round-trip
Most JWT tutorials skip the hard part: what happens when a refresh token is stolen? Here's how to detect reuse, revoke session families, and do it in under 5ms.
The API security defaults I wish every tutorial taught
Rate limits, idempotency keys, request signing, structured error envelopes, and input validation. Not advanced security — table stakes. Here's how to implement them in a day.