Auth Architecture: Sessions, JWT and Refresh Rotation — a Practical Choice
Over the past four years I have built auth for two serious projects: a school management system (thousands of students, parents and teachers) and a delivery service (a courier mobile app plus a dispatcher panel). In both, the very first architecture meeting raised the same question: "Do we use sessions or JWT?" And in both, the correct answer turned out to be "it depends." In this article I compare the three main approaches — server sessions, JWT, and the access+refresh pair with rotation — from the standpoint of real experience, and finish with concrete recommendations on what to pick in which situation.
Server sessions: old, but far from dead
The classic scheme is very simple. The user logs in, the server creates a session record (in Redis or Postgres) and puts its ID into an httpOnly cookie. On every subsequent request the browser sends the cookie automatically, the server looks the session up in the store and identifies the user.
Strengths:
- Revocation is one line of code. The user logged out or changed their password — you delete the record from the store, done. In the school system this served us well: when the principal says "cut this employee's access right now," it is cut within a second.
- The server stays in full control. How many active sessions exist, which devices they came from — everything is visible.
- The browser manages the cookie itself, no token juggling on the frontend.
Weaknesses:
- A store lookup on every request — with Redis that is microseconds, but still an extra dependency.
- With multiple servers you need a shared session store (relying on sticky sessions is fooling yourself).
- Mobile apps can work with cookies, but it feels unnatural — tokens are the norm in the mobile world.
JWT: the stateless promise and the real problems
The appeal of JWT fits in one sentence: the server stores nothing, verifies the signature inside the token and knows the user. Beautiful. In practice, though, there are three serious problems.
First — you cannot revoke it. A JWT stays valid until it expires. The user logged out? The token is still valid. The password was stolen and we changed it? The old token lives on. You say "we'll add a blacklist" — now you check a list on every request, which means state is back and your stateless advantage has evaporated.
Second — token theft. A token leaked through XSS or dropped into logs is a fully privileged key until it expires. If you set the lifetime to 7 days, the attacker walks freely through your API for 7 days.
Third — size. Start stuffing roles, permissions and profile data into the JWT and it balloons to 2-3 KB, riding along in the header of every request. In the delivery service we made exactly this mistake — the courier app on a slow connection was hauling useless kilobytes with every call.
The "JWT is needed everywhere" myth
Let me be blunt: in a "simple monolith + single frontend" combination, JWT is most often unnecessary complexity. There are really only two solid reasons to choose it:
- Microservices. One auth service signs the token, and dozens of other services verify the signature with a public key without touching a shared store. This is where stateless is genuinely valuable.
- Third-party APIs. If external partners call your API (OAuth 2.0 / OpenID Connect flows), you need a standard token format — that is JWT's natural territory.
If you have a Next.js frontend and a single Node.js backend, "everyone uses JWT" is not an architecture decision. I always tell my mentees: pick technology based on the problem, not the trend.
The access + refresh pair and rotation
The modern middle ground is a pair of a short-lived access token and a long-lived refresh token. The flow, in words:
- The user logs in. The server issues a 10-15 minute access token and a 30-day refresh token.
- The client uses the access token for API requests. The server only verifies the signature — no store lookups.
- When the access token expires, the client sends the refresh token to the
/auth/refreshendpoint. - The server finds the refresh token in the database, immediately revokes the old one and returns a new pair. This is rotation: every refresh token works exactly once.
- If a revoked (already used) refresh token shows up again — that is the reuse detection signal: the token was stolen. The server revokes the user's entire token family and requires a fresh login.
Why is reuse detection so important? Because without rotation, a stolen refresh token quietly works for 30 days and you never find out. With rotation, whichever party uses the old token second — the thief or the real user — the system notices instantly and kicks both out. In the worst case the user logs in again once — far cheaper than a stolen account.
Where to store the token: localStorage or an httpOnly cookie?
The most common SPA mistake is putting the token into localStorage. The problem is that any JavaScript on the page can read localStorage. One XSS vulnerability — someone injects a script through a comment, or an npm dependency gets poisoned — and the token is on the attacker's server.
An httpOnly cookie cannot be read by JavaScript at all, so the token cannot be stolen directly via XSS. In exchange you get CSRF risk: the browser attaches the cookie to every request automatically, which means a form on a foreign site could hit your API on the user's behalf. The good news is that today this problem is largely solved: the SameSite=Lax or Strict attribute blocks the cookie on cross-site requests.
My practical recommendation: keep both access and refresh tokens in cookies with `httpOnly` + `Secure` + `SameSite=Strict`, and restrict the refresh cookie's path to /auth/refresh only. Token theft via XSS is closed off, and SameSite covers CSRF.
Code: a refresh rotation skeleton in Express
Below is a simplified but working skeleton of the rotation and reuse detection logic. The database stores only a hash of the refresh token, not the token itself — even if the database leaks, the tokens are useless:
import crypto from "node:crypto";
import express from "express";
import cookieParser from "cookie-parser";
const app = express();
app.use(cookieParser());
const sha256 = (v) => crypto.createHash("sha256").update(v).digest("hex");
async function issueRefreshToken(userId, familyId) {
const raw = crypto.randomBytes(32).toString("base64url");
await db.refreshTokens.create({
hash: sha256(raw),
userId,
familyId,
expiresAt: new Date(Date.now() + 30 * 24 * 3600 * 1000),
});
return raw;
}
app.post("/auth/refresh", async (req, res) => {
const raw = req.cookies.refresh_token;
if (!raw) return res.status(401).json({ error: "no_token" });
const record = await db.refreshTokens.findByHash(sha256(raw));
// Reuse detection: a revoked token came back —
// it must be stolen, shut down the whole family
if (!record || record.revokedAt) {
if (record) await db.refreshTokens.revokeFamily(record.familyId);
return res.status(401).json({ error: "reuse_detected" });
}
if (record.expiresAt < new Date()) {
return res.status(401).json({ error: "expired" });
}
// Rotation: revoke the old one, issue a new one
await db.refreshTokens.revoke(record.id);
const newRaw = await issueRefreshToken(record.userId, record.familyId);
res.cookie("refresh_token", newRaw, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/auth/refresh",
});
res.json({ accessToken: signAccessToken(record.userId) }); // 15 minutes
});In production you would add rate limiting, wrap findByHash + revoke in a single transaction and add an audit log, but the core logic is exactly this.
Practical recommendations: what goes where?
- Simple web app (monolith, server-side rendering, admin panel): server sessions + Redis. The simplest, most controllable option. The admin part of our school system runs exactly on this and has never let us down.
- SPA + API (React/Vue frontend, separate backend): the access + refresh pair, both in
httpOnlycookies withSameSite=Strict. Do not put tokens in localStorage. - Mobile app: refresh rotation is mandatory. Keep tokens in the platform's secure storage (Keychain / Keystore). The courier app in the delivery service runs exactly this scheme — a 15-minute access token and a 30-day rotating refresh token.
- Microservices or partner-facing APIs: this is where JWT belongs — as a short-lived access token, with internal services verifying the signature independently.
Conclusion
In auth architecture there is no "best" solution, there is context. Sessions mean simplicity and control, JWT is a tool for carrying trust between services, and refresh rotation is the practical balance between the two. The two most important rules: never store a token where JavaScript can read it, and if you use refresh tokens, never use them without rotation. Everything else is an engineering decision that depends on the scale of your project.