JWT or Sessions: Pick the Boring One
05 May 2026Sessions are the right default. JWTs solve a problem most small apps don’t have, and they trade it for a problem you’ll actually hit: you can’t revoke one.
The claim JWT fans make
“Stateless” — no database lookup on every request, so it scales better. True, and irrelevant for the traffic a solo dev’s app sees. A session lookup against an indexed table or Redis is sub-millisecond. You will hit other bottlenecks in your app long before session lookups matter.
The problem JWT fans don’t mention: revocation
A session is a row in a database. Delete the row, the user is logged out, instantly, everywhere.
A JWT is a self-contained token the server didn’t have to store to issue. That’s the whole appeal — and the whole problem. Once issued, it’s valid until it expires, and the server has no built-in way to say “actually, not this one” before then. Someone’s laptop gets stolen, you want to kill their session — with sessions, one DELETE. With JWTs, you need a separate revocation mechanism (a blocklist, which is just… a session store again, defeating the statelessness), or you accept the token is valid for its full lifetime regardless.
Short-lived JWTs (5-15 minutes) with refresh tokens mitigate this, but now you’ve built session-like infrastructure around the refresh token anyway — you’re maintaining two systems for one problem sessions solve with one table.
Size and where it lives
A session is an opaque ID, typically ~32 bytes. A JWT carries its claims — user ID, roles, issued-at, expiry — base64-encoded in every single request:
# session cookie
sid=Xj4k9nP2mQ...
# JWT
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0IiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
That’s several hundred bytes on every API call, every image request if you’re sending it as a header, adding up on mobile connections. Small, but it’s overhead sessions don’t have.
When JWTs actually earn their complexity
- Multiple independent services need to verify identity without a shared database — a microservices setup where the auth service and the API service don’t share infrastructure. If you’re a solo dev with one Postgres instance, this isn’t your situation.
- Third-party API access — issuing a token to an external client who’ll present it to your API without a login flow each time. This is a real use case, but it’s
access tokens for API clients, nothow my own web app should log users in.
If neither applies to you, you don’t need JWTs.
The boring, correct default
// express-session with a real store, not memory
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
cookie: { secure: true, httpOnly: true, sameSite: 'lax', maxAge: 1000 * 60 * 60 * 24 * 7 },
}))
httpOnly stops JavaScript from reading the cookie, which is your actual defense against XSS token theft — the thing people cite as JWT’s advantage over cookies is solved by a cookie flag, not by switching auth strategies. secure forces HTTPS-only transmission. sameSite: 'lax' blocks most CSRF vectors without breaking normal navigation.
The concrete next action
If you’re mid-build and haven’t picked yet: sessions with a Redis or Postgres store. Revisit JWTs only when you have a second service that genuinely can’t share that store — and by the time that’s true, you’ll know it, not guess it.