The mental model that fixes everything
JWT is just a token format. It is not authentication, not a session, and not a database. Once you separate those ideas, most of the pain disappears.
A JWT is a JSON object that is signed. That's it. The payload holds claims like sub (subject) and exp (expiration). The signature proves the token wasn't tampered with.
What JWT is not
- Not a session store: You can't revoke a JWT before it expires. If you need revocation, you need a blocklist or short expiry.
- Not a database: Don't stuff heavy data in the payload. It gets sent on every request.
- Not a magic bullet: It's a way to pass claims between parties without a shared server-side state.
The three flows that matter
1. Access token only
Simplest flow: login returns a JWT, client sends it in the Authorization header, server verifies it on every request.
// server middleware (Express example)
const jwt = require('jsonwebtoken');
function auth(req, res, next) {
const header = req.headers.authorization;
if (!header) return res.status(401).json({ error: 'No token' });
const token = header.split(' ')[1]; // Bearer <token>
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch (err) {
res.status(401).json({ error: 'Invalid token' });
}
}
Works fine for small apps, but every request hits your auth logic and the token can't be invalidated early.
2. Access + refresh token
Common pattern for SPAs. Access token lives 15 minutes, refresh token lives 7 days. The refresh token is stored securely (httpOnly cookie) and used only to get a new access token.
// issue tokens on login
const accessToken = jwt.sign({ userId }, process.env.JWT_SECRET, { expiresIn: '15m' });
const refreshToken = jwt.sign({ userId }, process.env.REFRESH_SECRET, { expiresIn: '7d' });
res.json({ accessToken });
res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: true, sameSite: 'strict' });
Refresh endpoint:
app.post('/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.sendStatus(401);
try {
const payload = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
const newAccessToken = jwt.sign({ userId: payload.userId }, process.env.JWT_SECRET, { expiresIn: '15m' });
res.json({ accessToken: newAccessToken });
} catch {
res.clearCookie('refreshToken');
res.sendStatus(403);
}
});
This gives you short-lived access tokens (less risk if leaked) and long-lived sessions without storing server-side state.
3. Stateless vs stateful
If you need to revoke tokens immediately (like on password change), you have two options:
- Keep a token version in your user table. Include
versionin the JWT payload. Bump the version to invalidate all old tokens. - Use a blocklist (Redis or DB) for revoked tokens. Check the blocklist before verifying.
Both add state. If you don't need revocation, stay stateless.
Common mistakes I see
- Storing JWT in localStorage: XSS can steal it. Use httpOnly cookies for refresh tokens, and keep access tokens in memory if possible.
- Putting sensitive data in payload: It's base64 encoded, not encrypted. Anyone can read it.
- Using the same secret for access and refresh: Use separate secrets. If one leaks, the other is still safe.
-
Not checking
exp: Most libraries do it automatically, but if you hand-roll, don't forget.
The decision checklist
Ask these before adding JWT:
- Do you need to revoke tokens? If yes, plan for a blocklist or versioning.
- Are you building an API for multiple clients? JWT works well.
- Is your server the only consumer? A simple session cookie might be easier.
Final thought
JWT is a tool, not a religion. Use it when it fits: stateless APIs, microservices, or cross-domain auth. For classic server-rendered apps, traditional sessions are often simpler. The confusion comes from mixing the token format with the auth strategy. Keep them separate and you'll be fine.
Top comments (0)