Last week an argument that was first written in 2016 hit the Hacker News front page again: Stop Using JWTs, 492 points, hundreds of comments. The linked gist is one developer, samsch, repeating the same claim for the tenth year in a row: JWTs are the wrong tool for keeping users logged in.
Most people's first reaction is the standard one: yeah yeah, old debate, my JWT setup is fine.
That reaction is worth testing. This article is that test. It covers what the argument actually claims, a short audit you can run against your own auth code today, the numbers on token size, and the step-by-step server-side session pattern the thread keeps pointing to. If your app stores a long-lived JWT anywhere on the client, some part of this will apply to code you shipped.
One scope note up front: this is not original security research. Everything here comes from the HN thread, the original gist, the 2016 cryto.net article it builds on, and the OWASP Session Management Cheat Sheet. The migration pattern below is the well-documented default that large providers already use. Verify against your own stack and scale before shipping it.
The argument in one paragraph
The claim is not "JWT is bad." It is narrower: JWTs are the wrong tool for browser login sessions. Three points do the heavy lifting:
-
You cannot revoke a stateless token. A JWT is valid until its
expclaim passes. If a token leaks, or a user's phone is stolen, or you demote an admin, that token keeps working until expiry. The only fix is building a token denylist, which is a database lookup on every request, which means you rebuilt session state anyway. - "Stateless" rarely survives contact with reality. The moment you add a denylist, refresh tokens, or a logout endpoint, you have server-side state. The cryto.net article calls this out directly: stateful JWTs are "functionally the same as session cookies, but without the battle-tested and well-reviewed implementations."
- The storage location is a trap. Cookies get HttpOnly, Secure, SameSite protection from the browser for free. localStorage gets none of that. Any script that runs on your page can read it. If you store the JWT in a cookie to fix this, you are back to needing CSRF protection, same as sessions.
And the rebuttal most people reach for is already answered in the thread. Google does not use JWTs to keep you logged in in the browser. It uses cookie sessions and uses JWTs only as short-lived single sign-on transport. "Stateless scales" only matters at a scale most apps never hit.
A 10-minute audit of your own auth code
The strongest part of the thread is not the theory. It is that most JWT session implementations fail the same three checks. Run these against your current project:
-
Can you actually log a user out? If logout only deletes the token on the client, the token itself is still valid. Copy it before logging out, paste it into curl, and it keeps working until
exp. On a 30-day token, that is a 30-day session you cannot kill. - Where does the token live? If it is in localStorage, a single XSS bug hands every active session to the attacker, and revocation will not save you because there is no revocation. If it is in a cookie, check whether HttpOnly, Secure, and SameSite are set.
- When does authorization actually change? If the role lives inside the token, then demoting an admin, disabling an account, or changing permissions takes effect only when the token expires. The database already knows the truth. The token is a stale copy of it.
If all three checks pass, your design is closer to the pattern in this article than to the one it argues against. If any check fails, the fix is the same migration everyone in the thread describes.
The size problem, measured
There is also a plain bandwidth cost. Here is a realistic JWT with a typical session payload: sub, name, email, role, iat, exp.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMWIyYzNkNGU1IiwibmFt
ZSI6IkphbmUgRG9lIiwiZW1haWwiOiJqYW5lQGV4YW1wbGUuY29tIiwicm9sZSI6InNlb
mlvcl9lbmdpbmVlciIsImlhdCI6MTc1NzUwMDAwMCwiZXhwIjoxNzU3NTg2NDAwfQ.xxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
That token is 247 bytes. A server-side session ID does the same job as an opaque 32 random bytes, base64url-encoded: 43 characters. So the JWT version costs roughly 6x the bytes on every authenticated request, for claims the server has to distrust and re-derive from the database anyway. Multiply by your request volume and every API call is paying for data you cannot trust at authorization time.
Now the migration. Below is the pattern, in Express because it reads clearly even if you write Java or Go. The same shape works in Spring Security, Rails, Django, Laravel, or whatever your stack is.
Step 1: Add a session store
You need one table. Postgres works, and most apps already run it:
CREATE TABLE user_sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ
);
CREATE INDEX idx_user_sessions_user_id ON user_sessions(user_id);
The session ID itself is 32 random bytes, base64url-encoded. Not a hash of the user ID. Not a JWT. Just crypto.randomBytes(32).
Why a database table and not Redis? Redis is fine too and probably the better call at higher traffic. But most apps already run Postgres, one less moving part is worth something, and an indexed lookup by primary key is microseconds. The OWASP Session Management Cheat Sheet only cares that the session data lives server-side and that the ID is meaningless, both true here.
Step 2: Issue a session on login
import crypto from "node:crypto";
async function createSession(userId) {
const sessionId = crypto.randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
await db.query(
`INSERT INTO user_sessions (id, user_id, expires_at)
VALUES ($1, $2, $3)`,
[sessionId, userId, expiresAt]
);
return sessionId;
}
Step 3: Set a real cookie, with every flag
This is the step most tutorials skip, and it is where the actual security lives:
app.post("/login", async (req, res) => {
const user = await verifyCredentials(req.body.email, req.body.password);
if (!user) return res.status(401).json({ error: "Invalid credentials" });
const sessionId = await createSession(user.id);
res.cookie("sid", sessionId, {
httpOnly: true, // JS cannot read it
secure: true, // HTTPS only
sameSite: "strict", // CSRF defense in depth
maxAge: 30 * 24 * 60 * 60 * 1000, // matches DB expiry
path: "/",
});
res.json({ ok: true });
});
The flags are not optional decoration. From the OWASP Session Management Cheat Sheet:
- HttpOnly is "mandatory to prevent session ID stealing through XSS attacks."
- Secure keeps the ID off plaintext connections.
- SameSite is "defense in depth against CSRF, not as a replacement for a CSRF token." Keep your CSRF token if your app needs one.
- The ID must be "meaningless" so nothing about your users leaks out of it.
Step 4: Validate on every request
export async function requireAuth(req, res, next) {
const sid = req.cookies.sid;
if (!sid) return res.status(401).json({ error: "Not logged in" });
const { rows } = await db.query(
`SELECT s.user_id, u.role
FROM user_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.id = $1
AND s.expires_at > now()
AND s.revoked_at IS NULL`,
[sid]
);
if (rows.length === 0) return res.status(401).json({ error: "Not logged in" });
req.user = rows[0]; // fresh role, fresh data, every request
next();
}
Notice what this buys you that the JWT version cannot: the role comes from the database on every request. Demote an admin, their permissions change on their next request, not on token expiry. No denylist middleware. No clock-watching the exp claim.
Step 5: Make logout and revocation real
// Logout: actually kill the session
app.post("/logout", async (req, res) => {
const sid = req.cookies.sid;
if (sid) {
await db.query(`UPDATE user_sessions SET revoked_at = now() WHERE id = $1`, [sid]);
}
res.clearCookie("sid", { httpOnly: true, secure: true, sameSite: "strict" });
res.json({ ok: true });
});
// Password change: kill every session, everywhere
await db.query(
`UPDATE user_sessions SET revoked_at = now()
WHERE user_id = $1 AND revoked_at IS NULL`,
[userId]
);
This is the step that makes the whole migration worth it. In the JWT design, logout is a client-side illusion: the button removes a token from the browser while the token itself stays valid anywhere it was already copied. In the session design, logout is one UPDATE statement, and "log out all devices" is the same statement filtered by user ID. Password change, account disable, admin demotion: all of them take effect on the next request.
Where JWTs are still the right tool
The counterargument in every reply thread is "but JWTs are useful," and the critics agree. JWTs are fine when the token is:
- Short-lived, minutes not weeks.
- Single purpose, prove one thing to one service.
- Backed by sessions, the issuing server has its own session anyway.
The cryto.net example is a download server: your app server issues a 5-minute single-use token, the client hands it to a stateless file server, done. That is a correct use of JWT. So is short-lived service-to-service auth between microservices.
What the argument says to delete: long-lived JWTs as the browser session mechanism, tokens in localStorage, and logout buttons that do nothing.
The decision list
If you are staring at your own auth code this weekend, here is the cheat sheet:
- Browser app, users log in: server-side sessions in an HttpOnly cookie. This is the default. Google does it this way.
- Microservices talking to each other: short-lived JWTs or mTLS, minutes of lifetime, one narrow purpose.
- Mobile or SPA calling your API: session cookie if the SPA is on your domain. If it genuinely cannot share cookies, short-lived access token plus rotating refresh token in an HttpOnly cookie, and accept the revocation pain.
- Third-party API access (OAuth-style): JWTs or opaque tokens with introspection. This is the territory the spec was designed for.
- You are not sure: sessions. Nobody ever got fired for server-side sessions.
The takeaway
The uncomfortable part of the thread is not really about JWTs. It is that most of us cargo-culted an auth pattern from tutorials written for a problem our apps do not have. Your app probably does not have Reddit-scale session stores. It has Postgres, already running, already backed up. The "stateless" scalability being optimized for is usually imaginary, and the cost is real: logout that does not log out, sessions that cannot be revoked, and an XSS surface that did not need to exist. Run the three checks above. If your code fails one, the migration in this article is the fix.
So, honest question for the comments: have you migrated off JWT sessions, or are you running them happily at scale? I am genuinely curious whether that 492-point thread changed anyone's production code or whether we will all be having this same argument again in 2028.
Top comments (0)