JWT tutorials only teach the easy part. Here's what happens after.
Most auth tutorials end at "user logs in, gets a token, done." And for a while, that felt fine to me too.
Then the uncomfortable questions showed up.
What if the refresh token is stolen? How do you actually revoke a session? How do you know which device is logged in?
That's the point where I realized I needed to build something real to understand auth properly. So I built refresh token rotation backed by server-side session tracking - and it changed the way I think about authentication entirely.
π The Problem With "Basic" JWT Auth
A lot of beginner tutorials go like this:
- β Create a token when the user logs in
- β Send it to the client
- β Verify it on protected routes
That works. Until it doesn't.
Fully stateless JWT auth makes some critical things hard:
- β You can't easily revoke sessions
- β You can't safely manage multiple devices
- β A stolen refresh token stays valid until it expires (which could be days or weeks)
- β "Logout" becomes a client-side illusion, not a server-side guarantee
π§ The Core Concept: Controlled Token Lifecycle
Instead of treating refresh tokens like permanent keys, I made them part of a controlled session lifecycle.
Here's the high-level approach:
| Token | Lifetime | Purpose |
|---|---|---|
| Access Token | Short-lived | Used for protected requests |
| Refresh Token | Longer-lived | Stored in httpOnly cookie, used to get a new access token |
Think of it like this:
πͺͺ Access token = visitor pass
π Refresh token = a controlled way to ask for a new pass
The access token should be disposable. The refresh token needs stricter handling.
πΊοΈ The Full Auth Flow
Here's the big picture of how everything fits together:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β AUTH FLOW OVERVIEW β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
ββββββββββββ
β Client β
ββββββ¬ββββββ
β POST /login
βΌ
βββββββββββββββββββ
β Auth Server β
β βββββββββββββ β
β Verifies creds β
β Creates sessionβ
ββββββββββ¬βββββββββ
β
ββββββββββββββββΌβββββββββββββββ
βΌ βΌ βΌ
ββββββββββββββββ βββββββββββ βββββββββββββββββ
β Access Token β β Refresh β β Session Row β
β (short TTL) β β Token β β in Database β
ββββββββββββββββ β(httpOnlyβ β userId, ip, β
β cookie) β β userAgent, β
βββββββββββ β tokenHash β
βββββββββββββββββ
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PROTECTED REQUEST β
β β
β Client ββββ Access Token βββββΊ Protected Route β
β β β
β Token valid? βββΊ β
OK β
β Token expired? βββΊ 401 β
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β TOKEN REFRESH β
β β
β Client ββββ Refresh Token βββΊ /refresh β
β β β
β Hash match in DB? β
β Session revoked? β
β Already used? βββΊ THEFT β
β β β
β β
Issue new tokens β
β β Reject + revoke all β
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β»οΈ What Refresh Token Rotation Actually Means
When the client asks for a new access token, the server does not keep trusting the same refresh token forever.
Instead:
- π Verify the current refresh token's signature
- π Match its hash to an active, non-superseded session in the DB
- π Generate a new refresh token
- π Mark the old token as superseded (not just replace the hash)
- π« Old refresh token stops working immediately
ROTATION CYCLE:
Client Server Database
β β β
βββ POST /refresh ββββββΊ β β
β {refreshToken} βββ findSession(hash) βββΊ β
β ββββ session found βββββββ β
β β β
β βββ generate newRefreshToken
β βββ mark old as superseded βΊβ
β βββ store new hash βββββββββΊβ
β β β
ββββ {accessToken, ββββββββ β
β newRefreshToken} β β
β β β
β [old token is now dead] β β
TOKEN THEFT DETECTION:
Attacker uses stolen (already-rotated) token
β
βΌ
Server looks up hash - finds it was already superseded
β
βΌ
β οΈ REUSE DETECTED - revoke the ENTIRE session family
β
βΌ
Both the legitimate user and attacker are logged out
The compromise is contained β
Here's the implementation with theft detection included:
export async function rotateRefreshToken(refreshToken) {
// 1. Verify the incoming token is cryptographically valid
const decoded = jwt.verify(refreshToken, config.JWT_SECRET);
const refreshTokenHash = hashValue(refreshToken);
// 2. Look it up in the DB - find ANY matching session (revoked or not)
const session = await sessionModel.findOne({ refreshTokenHash });
if (!session) {
// Token hash not found at all - truly invalid
throw new AppError(401, "Invalid refresh token");
}
// 3. If the token was already rotated (superseded), this signals THEFT
// Revoke the entire session to protect the legitimate user
if (session.superseded || session.revoked) {
await sessionModel.updateMany(
{ userId: session.userId },
{ revoked: true, revokedAt: new Date() }
);
throw new AppError(401, "Token reuse detected. All sessions revoked.");
}
// 4. Generate a brand new refresh token
const newRefreshToken = signRefreshToken({ id: decoded.id });
// 5. Mark the old token as superseded and store the new hash atomically
session.superseded = true;
session.refreshTokenHash = hashValue(newRefreshToken);
await session.save();
// 6. Return both new tokens to the client
return {
accessToken: signAccessToken({
id: decoded.id,
sessionId: session._id,
}),
refreshToken: newRefreshToken,
};
}
β οΈ Why theft detection matters: Without it, if an attacker steals and uses a refresh token before the legitimate user does, the server happily rotates it for the attacker. The real user's next request just silently fails with "invalid token." They have no idea they were compromised. With reuse detection, the moment a superseded token is presented, the entire session gets revoked - limiting the blast radius and signaling that something is wrong.
π Why I Hash the Refresh Token (and You Should Too)
This was one of those "oh yeah, of course" moments.
If refresh tokens are powerful, storing them in plain text is risky.
So instead of storing the raw token, I store a SHA-256 hash of it:
import crypto from 'crypto';
function hashValue(value) {
return crypto
.createHash('sha256')
.update(value)
.digest('hex');
}
Why this matters:
| Scenario | Without Hashing | With Hashing |
|---|---|---|
| DB is compromised | π Attacker has live tokens | β Only useless hashes |
| Insider threat | π Employee can steal sessions | β Can't derive tokens from hashes |
| Data breach logged | π Tokens in log files are exploitable | β Hashes are worthless |
π SHA-256 vs bcrypt - important distinction:
You might wonder: why SHA-256 and not bcrypt like we use for passwords?Passwords are low-entropy human-chosen strings - they need slow, salted hashing (bcrypt, Argon2) to resist brute force.
Refresh tokens are cryptographically random, high-entropy values (typically 256+ bits of CSPRNG output). Brute-forcing a random 256-bit value is computationally infeasible regardless of hash speed. SHA-256 is fast, collision-resistant, and perfectly appropriate here. Using bcrypt on a refresh token would add unnecessary latency with zero security benefit.
π± Why Session Tracking Is the Real Upgrade
This is where the system moves from "auth demo" to "actual auth system."
Each login creates a session record in the database:
// Session schema
{
userId: ObjectId, // which user
refreshTokenHash: String, // hashed token (NOT the raw token)
ip: String, // where they logged in from
userAgent: String, // which browser / device
revoked: Boolean, // has this been explicitly killed?
revokedAt: Date, // when was it killed?
superseded: Boolean, // has this token already been rotated?
createdAt: Date,
}
With session tracking, the backend can now answer real questions:
- β Which sessions are currently active?
- β Which device or browser logged in?
- β Has this session been revoked?
- β Was this token already rotated (possible theft)?
- β Should this refresh token still be trusted?
SESSION REVOCATION FLOW:
User clicks "Log out of all devices"
β
βΌ
βββββββββββββββββββββββ
β Mark ALL sessions β
β revoked: true β
β revokedAt: now() β
βββββββββββββββββββββββ
β
βΌ
Any future refresh attempt
finds revoked: true βββΊ 401
All devices logged out
instantly. No waiting
for token expiry. β
Without this, JWT auth is like sending signed permission slips into the wild and hoping they behave.
π€ Why Not Just Use Stateless JWT Everywhere?
Because stateless JWT is great - until you need stateful behavior.
And auth almost always needs stateful behavior:
| Need | Stateless JWT | With Session Tracking |
|---|---|---|
| Logout one device | β Token lives until expiry | β Revoke that session |
| Logout all devices | β Impossible cleanly | β Revoke all sessions |
| Immediate invalidation | β Can't do it | β
Flip revoked: true
|
| Detect stolen tokens | β No awareness | β Reuse detection built-in |
| Track suspicious sessions | β No awareness | β IP + userAgent logged |
| Rotate tokens safely | β Risky | β Hash rotation + superseded flag |
π‘ The biggest insight:
JWT alone solves identity proof.
Session state solves lifecycle control.
In real apps, lifecycle control matters a lot.βοΈ One honest tradeoff: Including
sessionIdin the access token payload (as shown in the code) is a deliberate design choice that makes the access token semi-stateful. It only adds value if you validate the session on every protected request - otherwise it's payload bloat. If you validate it server-side on every request, you're giving up some of the "stateless" benefit of JWT in exchange for tighter session control. Know the tradeoff before you make it.
π΅ What Was Actually Hard to Build
The tricky part wasn't getting token generation to work. Generating tokens is easy.
The hard part was making the flow safe and consistent:
- π Making rotation happen without breaking the client's flow
- π« Ensuring old refresh tokens immediately stop working
- π΅οΈ Building reuse detection that protects users when tokens are stolen
- ποΈ Designing sessions so they can be revoked individually or all at once
- π§ͺ Keeping the logic clean enough to actually test
This is the gap between a "tutorial auth system" and something production-worthy.
π Key Lessons Learned
1. Refresh tokens shouldn't be treated casually
They're long-lived and powerful. Hash them. Rotate them. Track them. Detect reuse.
2. Session tracking gives your backend real control
Without it, you're flying blind. You can't revoke what you can't see.
3. Stateless auth is great - for the right problems
Short-lived access tokens? Stateless is perfect. Long-lived refresh tokens? You need server state.
4. Revocation becomes easy when sessions exist
Want to kill a session? Set revoked: true. Done. No waiting for token expiry.
5. Reuse detection is what makes rotation actually secure
Rotation without reuse detection is like changing your locks but leaving the old key working. If a rotated token is presented again, revoke everything immediately.
6. Multiple devices change everything
The moment you support multiple devices, you need per-device session tracking. There's no clean alternative.
π― Final Thoughts
If you're building auth in Node.js and only using JWT on its own - I really encourage you to think about refresh token rotation and session tracking.
These two ideas changed the way I think about auth systems entirely.
The biggest lesson?
JWT gives you identity. Sessions give you control. You need both.
If you've built auth before, I'd love to know how you handled refresh tokens and sessions. Drop your approach in the comments π¬
Tags: #node #security #webdev #javascript
Top comments (0)