Session Tokens vs JWTs: The False Dichotomy
Source: Dev.to
The “JWT‑only” Approach
// 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.
Pros
- No database queries – every server can verify the token.
- “Stateless” authentication (whatever that means).
Cons
- Revocation problem – If a user is deleted or fired, their JWT is still valid for up to 7 days.
- Stale role data – Changing a user’s role doesn’t affect already‑issued tokens.
- No “log out everywhere” – Stolen laptops, lost devices, etc., keep their tokens alive until expiration.
Result: Fast (≈0.97 ms per request) and high‑throughput (≈5,527 req/s), but you can’t revoke tokens and data becomes stale instantly.
The “Session‑only” Approach
// 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 /* … */) {
// handle missing or expired session
}
Result: Safe but slow; the database becomes the limiting factor.
Why the Hybrid Approach Works
The hybrid method only touches storage when the access token expires (e.g., ~1 % of requests). For the remaining 99 % of requests you get JWT‑level speed with no network call.
A Quick Note on Redis
If you replace PostgreSQL with Redis for session storage, lookups drop to ~2–3 ms, but you’re still making an external call on every request. The hybrid approach eliminates even that call for the vast majority of traffic.
The “Microservices” Counter‑Argument
“Microservices don’t share a database, so JWTs are the only way to validate tokens independently.”
In practice, microservices already share:
- A database (or cluster) for persistence
- Redis (or another cache) for shared state
- Message queues, logging, monitoring, etc.
If you already have Redis for caching, session validation is cheap. The hybrid approach still wins by avoiding the network round‑trip for 99 % of requests.
The Solution: Short‑Lived Access Tokens + Long‑Lived Refresh Tokens
It’s not JWT vs. sessions; it’s JWT + sessions, each doing what it does best.
How It Works
// User logs in
async function login(email, password) {
const user = await authenticateUser(email, password);
// Refresh token – stored in DB (valid for 30 days)
const refreshToken = randomBytes(32).toString('hex');
await db.session.create({
userId: user.id,
refreshToken,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
});
// Access token – NOT stored, just a JWT (valid for 15 minutes)
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
SECRET,
{ expiresIn: '15m' }
);
// Send tokens to the client
res.cookie('accessToken', accessToken, { httpOnly: true });
res.cookie('refreshToken', refreshToken, { httpOnly: true });
}
- Access token (JWT) – short‑lived, verified locally, no DB/Redis hit.
- Refresh token (session) – long‑lived, stored in DB/Redis, used only when the access token expires or when the user explicitly logs out.
Refresh Flow (simplified)
// Refresh endpoint
async function refresh(req, res) {
const { refreshToken } = req.cookies;
const session = await db.session.findUnique({
where: { refreshToken },
include: { user: true }
});
if (!session || session.expiresAt /* … */) {
// handle missing or expired refresh token
return;
}
// Issue a new short‑lived access token
const newAccessToken = jwt.sign(
{ userId: session.user.id, role: session.user.role },
SECRET,
{ expiresIn: '15m' }
);
res.cookie('accessToken', newAccessToken, { httpOnly: true });
}
Takeaway: Session‑only turns your auth system into a DB bottleneck. Hybrid retains JWT speed while adding session‑style control.
Full benchmark results are in stat-tests/RESULTS.md, and the test code is available at stat-tests/test-three-auth-strategies.
Recommendations
- Use the hybrid approach for virtually everything. It gives you JWT‑level speed with session‑level security.
- JWT‑only only when tokens are extremely short‑lived (< 5 min) and you truly don’t care about revocation (rare).
- Session‑only only for tiny apps (< 10 req/sec) where simplicity outweighs performance concerns.
You don’t have to pick sides. Get the speed of JWTs and the control of sessions—just think about the trade‑offs instead of cargo‑culting a single approach.
Happy hacking!