DEV Community

Cover image for πŸ” I Finally Understood JWT Auth - After Building Refresh Token Rotation From Scratch
Anish Hajare
Anish Hajare

Posted on

πŸ” I Finally Understood JWT Auth - After Building Refresh Token Rotation From Scratch

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:

  1. βœ… Create a token when the user logs in
  2. βœ… Send it to the client
  3. βœ… 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  β”‚
      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

♻️ 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:

  1. πŸ” Verify the current refresh token's signature
  2. πŸ”Ž Match its hash to an active, non-superseded session in the DB
  3. πŸ†• Generate a new refresh token
  4. πŸ”„ Mark the old token as superseded (not just replace the hash)
  5. 🚫 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 βœ…
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

⚠️ 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');
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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. βœ…
Enter fullscreen mode Exit fullscreen mode

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 sessionId in 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)