Every application that has user accounts has to answer one question on every request: who is this? Two approaches dominate: server-side sessions and JSON Web Tokens (JWT). Both work, and both are used wrongly often.
This guide explains how each one works, what each costs you, and how to choose.
How Session Authentication Works
- The user logs in with a password.
- The server creates a session record, which holds the user ID and an expiry, in a store such as a database or Redis.
- The server sends the browser a cookie containing a random session ID.
- On each request, the browser sends the cookie, and the server looks the session up.
// Login
const sessionId = crypto.randomUUID()
await sessions.set(sessionId, { userId: user.id }, { ttl: 60 * 60 * 24 * 7 })
res.cookie('sid', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000,
})
// Later requests
const session = await sessions.get(req.cookies.sid)
if (!session) return res.status(401).end()
The cookie holds no user data, only a reference. Everything that matters stays on the server.
How JWT Authentication Works
- The user logs in with a password.
- The server creates a signed token containing claims such as the user ID and an expiry.
- The client sends the token with each request, usually in an
Authorizationheader or a cookie. - The server verifies the signature and reads the claims. It does not need to look anything up.
import jwt from 'jsonwebtoken'
// Login
const token = jwt.sign({ sub: user.id }, process.env.JWT_SECRET, {
algorithm: 'HS256',
expiresIn: '15m',
})
// Later requests
try {
const claims = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] })
req.userId = claims.sub
} catch {
return res.status(401).end()
}
A JWT has three parts: a header, a payload and a signature. The payload is encoded, not encrypted. Anyone who holds the token can read it, so never put secrets in it.
Side by Side
| Sessions | JWT | |
|---|---|---|
| State | Stored on the server | In the token |
| Per-request cost | A store lookup | A signature check |
| Revoke a login | Delete the session, effective immediately | Hard until the token expires |
| Scales across servers | Needs a shared store | Any server with the key can verify |
| Data in the client | Only an opaque ID | Readable claims |
| Size | Small cookie | Larger, grows with claims |
| Natural fit | Server-rendered web apps | APIs, mobile apps, service-to-service |
The Revocation Problem
This is the largest difference in practice. With a session, logging a user out, banning an account or forcing a password reset means deleting a row. The effect is immediate.
A JWT is valid until it expires, wherever it is. To revoke one before then, you need a denylist, and a denylist that is checked on every request is a server-side store again, which gives back the benefit that made JWT attractive.
The usual way to live with this is a short-lived access token, around 5 to 15 minutes, and a longer-lived refresh token. The refresh token is stored server-side and can be revoked. The damage from a stolen access token is limited by its short life.
Where to Store the Token in a Browser
This choice decides how exposed you are.
| Storage | Readable by page JavaScript | Sent automatically | Main risk |
|---|---|---|---|
localStorage | Yes | No | XSS steals the token |
Cookie with HttpOnly | No | Yes | CSRF, handled with SameSite and tokens |
Storing tokens in localStorage is common in tutorials and a poor default. Any cross-site scripting bug lets an attacker read the token and use it elsewhere. An HttpOnly, Secure, SameSite cookie keeps the token out of reach of page scripts. That advice applies to a session ID and to a JWT equally.
Security Checklist
For both approaches:
- Serve everything over HTTPS and set the
Secureflag. - Set
HttpOnlyon any cookie that carries a credential. - Set
SameSite=LaxorStrict, and add CSRF protection for state-changing requests when using cookies. - Hash passwords with a slow algorithm such as argon2 or bcrypt. Never store them in plain text.
- Rate-limit login attempts.
Specific to JWT:
- Pin the allowed algorithm when verifying. Never accept whatever the token header claims, and never accept
none. - Keep the signing key out of the code and rotate it on a schedule.
- Always set an expiry and keep it short.
- Check the issuer and audience if more than one service issues tokens.
Specific to sessions:
- Generate IDs with a cryptographically secure random source, and make them long.
- Issue a new session ID after login to prevent session fixation.
- Expire idle sessions.
Which One Should You Use?
Choose sessions when:
- You are building a conventional web app that the browser talks to directly.
- You want instant logout and easy account lockout.
- One database or Redis instance is acceptable to your architecture.
Choose JWT when:
- Several independent services must verify a user without sharing a session store.
- A mobile app or third-party client calls your API.
- You use a standard that is built on tokens, such as OpenID Connect.
For many teams the honest answer is sessions. They are simpler to reason about, easier to revoke and harder to get wrong. A JWT is a good tool for a specific problem, which is verifying identity across systems without a shared lookup, and it is a worse choice when it is used only because it is popular.
Using Both
A common pattern combines them: a session or refresh token at the identity provider, and short-lived JWTs passed to downstream services. The provider can revoke the session, and the services verify tokens without calling it.
Common Mistakes
- Treating a JWT as encrypted. The payload is readable by anyone who has the token.
- Long expiry with no revocation. A token valid for 30 days is a 30-day exposure.
- Storing the token in
localStorage. One XSS bug exposes every user. - Not pinning the verification algorithm. It opens the door to forged tokens.
- Putting changing data in the token. Roles and permissions in a JWT go stale until it expires.
- Rolling your own crypto or session logic. Use a maintained library or your framework's auth support.
Summary
Sessions keep state on the server and make revocation easy. JWTs keep state in a signed token and make verification stateless. For a typical web app, start with sessions in an HttpOnly cookie. Reach for JWTs when you need to verify identity across services or clients that cannot share a session store.
