I've been building auth systems for a while now, and there's this debate that keeps coming up: JWTs or sessions? Every tutorial forces you to pick one, then spends 2000 words explaining why the other one is terrible.
Here's the thing though: this entire debate is pointless. You don't have to choose. There's a third option that gives you the best of both, and honestly, it's simpler than either approach on its own.
Let me show you what I mean.
The JWT-Only Approach (and Why It's Dangerous)
Most tutorials will show you something like this:
// User logs in
const jwt = sign(
{ userId: user.id, role: user.role },
SECRET,
{ expiresIn: '7d' }
);
setCookie('token', jwt);
// Every request
const payload = verify(req.cookies.token, SECRET);
// Done. No database hit.
This looks clean. No database queries. Any server can verify the token. Your auth is "stateless" (whatever that means).
But here's what actually happens in practice:
A user gets fired. The admin deletes their account from the database. Their JWT? Still valid for the next 7 days. They still have full access to your system whilst everyone thinks they're locked out.
Or this: You change someone's role from Admin to User. The JWT still says role: 'admin' for the next 7 days. The database is updated, but the token doesn't care.
Or my personal favourite: A user's laptop gets stolen and they want to log out of all devices. With JWTs alone, you can't do it. The tokens are out there in the wild, and they're valid until they expire. There's no "logout all sessions" button that actually works.
What you get:
- Fast (0.97ms average response time, no database lookups)
- High throughput (5,527 requests/sec under load)
- But you can't revoke tokens
- And the data goes stale immediately
At small scale, maybe you don't care. But the moment you need to actually control who has access to your system? JWTs alone are a disaster.
The Session-Only Approach (and Why It Doesn't Scale)
Right, so JWTs are dangerous. Let's just use sessions:
// User logs in
const sessionId = randomBytes(32).toString('hex');
await db.session.create({
id: sessionId,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});
setCookie('sessionId', sessionId);
// Every request - database lookup
const session = await db.session.findUnique({
where: { id: req.cookies.sessionId },
include: { user: true }
});
if (!session || session.expiresAt < new Date()) {
return res.status(401).json({ error: 'Invalid session' });
}
This fixes all the JWT problems. Delete the session? User is logged out immediately. Change their role? Next request sees it. Perfect.
Here's the cost: every single request hits your database. Not most requests. Not some requests. Every request.
Your app makes 50 API calls to load the dashboard? That's 50 database queries just for auth checks. Before you even get to your actual business logic, you've already hammered your database 50 times.
What you get:
- Instant revocation (delete from DB, user is logged out)
- Fresh data (always reflects current state)
- But every request costs 1.52ms in database latency
- And your database becomes the bottleneck for everything
I tested this. At 4,561 requests per second, the session-only approach was hitting the database with 4,561 queries per second just for auth checks. That's before your actual business logic runs. Your database will melt.
This approach works fine at small scale. But the moment you hit any real traffic, you've just made authentication the most expensive operation in your entire system.
Here's what's actually happening under the hood with each approach:
See the problem? JWT-only never touches the database (fast but dangerous). Session-only hits it every time (safe but slow). The hybrid approach only hits the database when the access token expires—about 1% of requests.
A Quick Note on Redis
If you're thinking "just use Redis instead of PostgreSQL for sessions," you're right—that's faster. Redis lookups are ~2-3ms instead of 5-20ms for PostgreSQL. But you're still hitting external infrastructure on every request, which is the core issue.
The hybrid approach below gives you JWT-speed (0.5ms, no network call) for 99% of requests, and only checks storage (Redis or PostgreSQL) when tokens expire. That's the key difference: frequency, not just speed.
Why "JWTs Are for Microservices" Is Bullshit
Before I show you the solution, let's address the argument I always hear:
"But microservices! They don't share a database! JWTs let each service validate tokens independently!"
Look at your microservices architecture. Actually look at it.
User Service → PostgreSQL
Order Service → PostgreSQL
Payment Service → PostgreSQL
Product Service → PostgreSQL
Your services already share:
- The database (or database cluster)
- Redis for caching
- Message queues
- Logging infrastructure
- Monitoring tools
So why exactly can't auth share Redis? If you've got Redis for caching (which you do), session validation takes 2-3ms. That's fast, sure. But the hybrid approach below gives you 0.5ms response times by skipping even that network call 99% of the time.
The real reason people use JWTs? They read one article that said "JWTs are stateless and scalable" and never questioned it. Bottom line is: if you have Redis, the "distributed systems need JWTs" argument falls apart.
The Solution: Short Access Tokens + Long Refresh Tokens
Here's what I actually use: short-lived access tokens (JWTs) backed by long-lived refresh tokens (sessions in the database).
It's not JWT vs Sessions. It's JWT and Sessions, each doing what they're good at.
Here's how it works:
// User logs in
async function login(email, password) {
const user = await authenticateUser(email, password);
// Refresh token - stored in database (lasts 30 days)
const refreshToken = randomBytes(32).toString('hex');
await db.session.create({
userId: user.id,
refreshToken: refreshToken,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
});
// Access token - NOT stored, just a JWT (lasts 15 minutes)
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
SECRET,
{ expiresIn: '15m' }
);
res.cookie('accessToken', accessToken, { httpOnly: true });
res.cookie('refreshToken', refreshToken, { httpOnly: true });
}
Let me show you how this flows in practice:
The key insight: most requests take the fast path (top). Only when the access token expires do you hit the database to validate the refresh token and issue a fresh access token with updated user data.
Now here's what the protect middleware looks like:
async function protect(req, res, next) {
const { accessToken, refreshToken } = req.cookies;
try {
// Fast path - verify access token (no database)
const payload = jwt.verify(accessToken, SECRET);
req.userId = payload.userId;
return next();
} catch (err) {
// Access token expired - check refresh token (database lookup)
if (!refreshToken) {
return res.status(401).json({ error: 'Not authenticated' });
}
const session = await db.session.findUnique({
where: { refreshToken },
include: { user: true }
});
if (!session || session.expiresAt < new Date()) {
return res.status(401).json({ error: 'Session expired' });
}
// Issue new access token
const newAccessToken = jwt.sign(
{ userId: session.userId, role: session.user.role },
SECRET,
{ expiresIn: '15m' }
);
res.cookie('accessToken', newAccessToken, { httpOnly: true });
req.userId = session.userId;
return next();
}
}
What this gives you:
99% of requests verify the access token and skip the database entirely. Fast.
1% of requests (when the 15-minute token expires) hit the database to validate the refresh token and issue a new access token. Controlled.
User gets fired? Delete their refresh token. Their current access token works for max 15 minutes, then they're locked out. That's not instant, but it's way better than 7 days.
Role changed? When the access token expires (15 minutes max), the new one includes fresh data from the database.
Want to log out all devices? Delete all refresh tokens for that user. Every device loses access within 15 minutes.
What About High-Security Scenarios?
For most applications, the 15-minute window is fine. But if you're building something where instant revocation is critical (banking, healthcare, admin panels), you have options:
Option 1: Shorter access tokens
Use 5-minute or even 1-minute access tokens. More frequent refresh checks, but still way better than hitting the DB on every request.
Option 2: Redis blacklist
Maintain a blacklist of revoked access tokens in Redis. Check it on every request:
async function protect(req, res, next) {
const { accessToken } = req.cookies;
try {
const payload = jwt.verify(accessToken, SECRET);
// Check blacklist (Redis is fast, ~1ms)
const isBlacklisted = await redis.get(`blacklist:${payload.jti}`);
if (isBlacklisted) {
return res.status(401).json({ error: 'Token revoked' });
}
req.userId = payload.userId;
return next();
} catch (err) {
// ... refresh token flow
}
}
This trades some performance (1ms Redis check on every request) for instant revocation. You're still not hitting PostgreSQL, and Redis can handle way more load than your database.
Which to choose?
- Most apps: 15-minute window is fine
- Financial/Healthcare: 5-minute tokens or Redis blacklist
- Admin panels: 1-minute tokens
Here's the comparison:
| Approach | Response Time | Req/sec | DB Queries/sec | Revoke Access? | Fresh Data? |
|---|---|---|---|---|---|
| JWT-only | 0.97ms | 5,527 | 0 | ❌ No (7 days) | ❌ Stale (7 days) |
| Session-only | 1.52ms | 4,561 | 4,561 | ✅ Instant | ✅ Always |
| Hybrid | 0.51ms | 5,494 | ~0 | ✅ 15 min max | ✅ 15 min max |
Let's make this concrete. Say your app handles 5,000+ requests per second:
The hybrid approach gives you 99% of JWT's performance with session's security. That's not a compromise—it's getting the best of both.
I ran these benchmarks on PostgreSQL with 100 concurrent connections. Here's what actually happened:
Single request performance:
- Hybrid: 0.51ms average
- JWT-only: 0.97ms average
- Session-only: 1.52ms average
The hybrid approach is actually faster than JWT-only because the access token verification is so lightweight. No database connection overhead. No query execution. Just JWT validation.
Under load (5,000+ requests/sec):
- JWT-only: 5,527 req/sec, 0 database queries
- Hybrid: 5,494 req/sec, ~0 database queries (99%+ fast path)
- Session-only: 4,561 req/sec, 4,561 database queries/sec
See the problem? Session-only turns your auth system into a database bottleneck. Every single request waits on the database before it can do anything useful.
If you want to see the full benchmark results, I've published them here: stat-tests/RESULTS.md. The actual test code is also available if you want to run it yourself: stat-tests/test-three-auth-strategies.
When to Use What
Use the hybrid approach for pretty much everything. Seriously. Unless you have a specific reason not to, this is the pattern.
Use JWT-only if tokens are extremely short-lived (< 5 minutes) and you genuinely don't care about revocation. This is rare.
Use session-only if your app gets less than 10 requests per second total and you want to keep things simple.
That's it. You don't have to pick sides. Get the speed of JWTs with the control of sessions. It's not complicated, it just requires actually thinking about the trade-offs instead of cargo-culting whatever approach you read about first.
Happy Hacking!




Top comments (1)
Nice piece!
JWTs aren’t inherently non-revocable. You can track revoked tokens, but doing so shifts you back toward statefulness and introduces scaling concerns. If you’re going to pay that cost, Redis is the only sane place to do it. But you should not make the mistake of maintaining a revoked list of short-time tokens, since they are going to expire very soon anyway, or should you?