Every auth discussion eventually comes down to this: JWT or database sessions? People have strong opinions, some of them correct. We've shipped both in production, made mistakes with both, and have the late-night Slack messages to prove it. This post is what we wish someone had told us before we stored sensitive data in a JWT payload and called it a day.
How Each One Actually Works
A JWT (JSON Web Token) is a signed token — typically stored in a cookie or localStorage — that contains claims about the user. The server mints it, signs it with a secret, and ships it to the client. On every subsequent request, the client sends the token back, and the server verifies the signature without hitting the database. Stateless. Fast. Elegant on paper.
A database session works differently. The server creates a session record in the database (or Redis, or wherever), generates a random session ID, and sends that ID to the client as a cookie. On every request, the server looks up the session ID, finds the associated data, and proceeds. Stateful. One extra DB round-trip. Boring. Reliable.
// JWT approach — server creates a signed token
import { SignJWT, jwtVerify } from 'jose';
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export async function createToken(userId: string, role: string) {
return new SignJWT({ userId, role })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(secret);
}
export async function verifyToken(token: string) {
const { payload } = await jwtVerify(token, secret);
return payload as { userId: string; role: string };
}// Database session approach — server stores session, client gets opaque ID
import { db } from '@/lib/db';
import { sessions } from '@/lib/schema';
import { randomBytes } from 'crypto';
import { eq } from 'drizzle-orm';
export async function createSession(userId: string) {
const sessionId = randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await db.insert(sessions).values({
id: sessionId,
userId,
expiresAt,
createdAt: new Date(),
});
return sessionId;
}
export async function getSession(sessionId: string) {
const [session] = await db
.select()
.from(sessions)
.where(eq(sessions.id, sessionId))
.limit(1);
if (!session || session.expiresAt < new Date()) return null;
return session;
}The JWT Problem Nobody Tells You About
JWTs sound great until a user does something that should immediately invalidate their session. Think: password reset, account suspension, role change, "log out all devices". With a JWT, you literally can't invalidate the token before it expires. The server has no record of it. It's out there in the wild, valid until the expiry timestamp says otherwise.
The workarounds are all awkward. You can keep a token blocklist in Redis — but now you're making a DB call on every request anyway, which is exactly what you were trying to avoid. You can use very short expiry times (15 minutes) with refresh tokens, which works but adds significant complexity. Or you can just... accept the limitation and design your app around it. Some apps can. Many can't.
If your app lets users change their password, suspend accounts, or revoke access, and you're using long-lived JWTs without a blocklist — you have a security hole. Not a theoretical one. A real one.
We learned this with an early project. A user changed their password, we issued a new JWT, but their old token was still valid for another 6 days. Nothing catastrophic happened, but we quietly added a token version field to our users table and started embedding it in every JWT. Verify the signature AND that the token version matches. Suddenly you're doing a DB lookup again. So much for stateless.
Where JWTs Are Actually Good
Don't let the above scare you off JWTs entirely. There are real, legitimate use cases where they shine.
- Short-lived tokens for email verification or password reset links — mint a token, embed it in a URL, validate once, done
- Cross-service auth in a microservices setup — Service A mints a token, Service B verifies it without calling Service A's database
- Stateless API keys for machine-to-machine communication with short expiry
- Mobile apps where you control the token storage and have clear refresh token logic
The key word is "short-lived". A 15-minute access token with a secure refresh token rotation strategy? Solid. A 30-day JWT because you don't want to deal with sessions? That's where pain lives.
Database Sessions: The Boring Choice That Wins
For most web apps — especially SaaS products where users log in, do things, and log out — database sessions are just the right tool. You can invalidate them instantly. You can store arbitrary session data. You can see all active sessions for a user and let them terminate specific ones. "Log out everywhere" is a single DELETE query.
// Session management helpers — these cover 90% of what you need
import { db } from '@/lib/db';
import { sessions } from '@/lib/schema';
import { eq, and, lt } from 'drizzle-orm';
// Log out a single session
export async function invalidateSession(sessionId: string) {
await db.delete(sessions).where(eq(sessions.id, sessionId));
}
// Log out all sessions for a user (password change, account compromise, etc.)
export async function invalidateAllUserSessions(userId: string) {
await db.delete(sessions).where(eq(sessions.userId, userId));
}
// Keep your sessions table clean — run this as a cron or on login
export async function deleteExpiredSessions() {
await db.delete(sessions).where(lt(sessions.expiresAt, new Date()));
}
// Rotate session ID after privilege escalation (login, sudo mode, etc.)
export async function rotateSession(oldSessionId: string) {
const [existing] = await db
.select()
.from(sessions)
.where(eq(sessions.id, oldSessionId))
.limit(1);
if (!existing) throw new Error('Session not found');
const { randomBytes } = await import('crypto');
const newSessionId = randomBytes(32).toString('hex');
await db.transaction(async (tx) => {
await tx.delete(sessions).where(eq(sessions.id, oldSessionId));
await tx.insert(sessions).values({
...existing,
id: newSessionId,
createdAt: new Date(),
});
});
return newSessionId;
}The main objection to database sessions is the extra round-trip on every request. In practice, with a properly indexed sessions table and connection pooling, this is single-digit milliseconds. If you're running a serverless Next.js app on Vercel with a Postgres database, you're already paying that latency on every server action anyway. One more lookup isn't going to tank your performance.
The Hybrid Approach (Access + Refresh Tokens)
If you genuinely need the scalability benefits of stateless auth — high-traffic APIs, multiple services, mobile clients — the industry-standard answer is short-lived JWTs paired with long-lived refresh tokens stored in the database.
Access token lives for 15-60 minutes. No DB lookup needed for most requests. Refresh token lives in your database, linked to the user, and is used only to generate new access tokens. When you need to invalidate everything, you delete the refresh token. The access token will expire naturally within its short window.
// Simplified refresh token rotation
import { db } from '@/lib/db';
import { refreshTokens } from '@/lib/schema';
import { eq } from 'drizzle-orm';
import { createToken } from './jwt';
import { randomBytes } from 'crypto';
export async function refreshAccessToken(incomingRefreshToken: string) {
const [storedToken] = await db
.select()
.from(refreshTokens)
.where(eq(refreshTokens.token, incomingRefreshToken))
.limit(1);
if (!storedToken || storedToken.expiresAt < new Date()) {
throw new Error('Invalid or expired refresh token');
}
// Rotate: delete old refresh token, issue new one
const newRefreshToken = randomBytes(32).toString('hex');
const newExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await db.transaction(async (tx) => {
await tx.delete(refreshTokens).where(eq(refreshTokens.token, incomingRefreshToken));
await tx.insert(refreshTokens).values({
token: newRefreshToken,
userId: storedToken.userId,
expiresAt: newExpiresAt,
});
});
const accessToken = await createToken(storedToken.userId, storedToken.role);
return { accessToken, refreshToken: newRefreshToken };
}Note the rotation: every time you use a refresh token, you get a new one and the old one is deleted. This means if an attacker steals a refresh token and uses it, the legitimate user's next refresh attempt fails, alerting you to a potential compromise. This is called refresh token rotation and it's standard practice. Don't skip it.
Practical Decision Guide
Here's how we actually think about this when starting a new project:
- Building a SaaS web app with Next.js? Use database sessions. Boring, reliable, easy to reason about.
- Building a public API that third parties consume? Short-lived JWTs, ideally with API key management on top.
- Need cross-service auth? JWTs between services, database sessions facing users.
- Building a mobile app? Access + refresh token pattern, refresh tokens stored server-side.
- Using NextAuth/Auth.js? It defaults to database sessions when you configure a database adapter — keep it that way.
The most expensive auth mistake isn't choosing JWT over sessions. It's choosing JWT because it sounds sophisticated and then shipping without a revocation strategy.
Whatever you pick, store tokens in httpOnly cookies — not localStorage. This protects against XSS attacks, which are far more common than people realize. An httpOnly cookie can't be read by JavaScript at all, only sent with requests. Set it as Secure (HTTPS only) and SameSite=Lax as a baseline. If you're handling really sensitive data, look at SameSite=Strict.
One more thing that bites people: always validate on the server. Doesn't matter how well you hide things on the client. If an authenticated route does something sensitive, check the session server-side. We've seen Next.js apps that check user roles client-side only. That's not auth, that's just vibes.
Our templates on peal.dev use database sessions by default — specifically, a sessions table with Drizzle + Postgres, secured with httpOnly cookies, with session rotation on login baked in. Not because JWTs are bad, but because for the web apps most people are building, database sessions have fewer sharp edges.
The bottom line: use database sessions unless you have a concrete, specific reason not to. "JWTs are stateless and that sounds better" is not a concrete reason. "We have three separate backend services that need to authenticate users without a shared database" is. Start simple, scale when the problem demands it.
