DEV Community

Mohamed Idris
Mohamed Idris

Posted on

How to Actually Log Out a User When You Use JWT

JWT is stateless and fast, but logout is tricky. Here is the full story with simple analogies, the jti blacklist trick, password change invalidation, refresh token rotation, and Node.js code.

The problem in one sentence

You log a user out, but their token still works until it expires. That is scary for any app that touches sensitive data.

This post walks through why that happens and how to fix it properly. I will keep it simple and use real life analogies so it sticks in your head.

This post was inspired by a great post from Mohamed Kamal in the Node.js Egypt group. He shared a solid pattern. I am going to explain the same idea slowly, then add the parts that make it production grade.

First, a mental model: the festival wristband

Imagine you go to a music festival. At the entrance they give you a wristband.

  • The wristband has your info printed on it: your name, your ticket type (VIP or normal), and the date it stops working.
  • The guard at every stage just looks at your wristband. He does not call the main office to ask "is this person still allowed in?"
  • That makes entry super fast. One guard can check thousands of people.

This wristband is a JWT (JSON Web Token).

The fact that the guard does not call the office is what we call stateless. The server does not keep a list of who is logged in. All the info is inside the token itself, and the signature proves it is real and was not edited.

This is great for speed and scaling. You can add 10 more servers and none of them need a shared "who is logged in" list. Any server can read the wristband and trust it.

So where is the problem?

You leave the festival early. You go home.

Your wristband is still on your wrist. It still says VIP. It still works until the festival ends.

If someone cuts it off your wrist (or you gave it to a friend), they can walk back in. The guard has no idea you "left". He only checks the wristband, and the wristband is still valid.

That is the JWT logout problem.

When a user clicks Logout, you usually just delete the token from the browser. But if a copy of that token was stolen earlier, it keeps working until the expiry time. The server never said "this token is dead now".

For a blog, who cares. For a banking app, a health app, or anything with private data, this is a real risk.

The naive fixes (and why they are not enough)

"Just make the token expire fast."
Good instinct, but if it expires every 5 minutes, the user has to log in again every 5 minutes. Annoying.

"Store every active token in the database and check it on every request."
This works, but now you call the database on every single request to ask "is this token still alive?". You just threw away the main benefit of JWT, which was being stateless and fast. At that point a normal session in the database is simpler.

So we need a middle path. We want most requests to stay fast and stateless, but we still want the power to kill a token when we need to.

The real solution, step by step

The pattern has a few moving parts. Let us build them one at a time.

Part 1: Two tokens, not one (access token + refresh token)

Stop using one long living token. Use two.

Access token

  • Short life. Think 5 to 15 minutes.
  • This is the festival wristband. The server trusts it without checking a database.
  • Used on every normal request (get profile, load dashboard, etc).

Refresh token

  • Long life. Think 7 to 30 days.
  • This is like your ID card kept at the front desk.
  • It has only one job: when your wristband expires, you show the ID card and get a fresh wristband.

Analogy: the wristband gets you into stages quickly. When it stops working after 15 minutes, you walk to the front desk, show your ID card, and they print you a new wristband. You do not need to buy a new ticket.

Why this matters for logout: because the access token only lives 15 minutes, even if logout is not perfect, a stolen access token dies on its own very soon. The damage window is tiny. The real control point becomes the refresh step.

Part 2: Give every token an ID (the jti)

jti means JWT ID. It is just a unique id you put inside the token when you create it. Usually a UUID.

Think of it as a serial number on the wristband.

Why do we need a serial number? Because if we ever want to ban one specific wristband, we need a way to point at it and say "that one, number 8f3a..., is banned".

const { v4: uuidv4 } = require("uuid");
const jwt = require("jsonwebtoken");

function createAccessToken(user) {
  return jwt.sign(
    {
      sub: user.id,
      role: user.role,
      jti: uuidv4(), // the serial number
    },
    process.env.ACCESS_SECRET,
    { expiresIn: "15m" }
  );
}
Enter fullscreen mode Exit fullscreen mode

Part 3: The blacklist (also called a denylist)

When the user logs out, we take the token serial number (jti) and put it on a banned list.

This is exactly like the festival having a small "banned wristbands" sheet at the front desk. The guards at the stages still do not check it (they stay fast). But the front desk does check it before giving out a new wristband.

So:

  • Logout puts the jti on the banned list.
  • The refresh step (front desk) checks the banned list before giving a new access token.
  • If the jti is banned, refresh is refused. No new wristbands for you. And since the old access token dies in a few minutes anyway, the user is fully out very soon.

This is the key trick. We did not check the blacklist on every request. We only check it at the refresh step. So normal requests stay fast and stateless, and we still get real logout.

Note: in the original post the blacklist was checked at refresh time. That is the smart, cheap choice as long as your access token is short lived. If your access token lives for hours, that small window becomes a big risk. Keep access tokens short.

Part 4: Auto cleanup with a TTL index

A banned list that grows forever is a problem. We do not need to remember a banned token after it would have expired anyway. A dead token cannot be used, banned or not.

MongoDB has a feature called a TTL index (Time To Live). You tell Mongo "delete this document automatically after this date". Mongo does the cleanup for you.

Analogy: the front desk shreds old banned wristband notes at the end of each night. No one keeps paper forever.

const mongoose = require("mongoose");

const blacklistSchema = new mongoose.Schema({
  jti: { type: String, required: true, index: true },
  // when the original token would have expired anyway
  expiresAt: { type: Date, required: true },
});

// TTL index: Mongo deletes the row when expiresAt is reached
blacklistSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });

module.exports = mongoose.model("BlacklistedToken", blacklistSchema);
Enter fullscreen mode Exit fullscreen mode

Part 5: Kill all old tokens when the password changes

Here is a nasty case. A hacker stole a user token. The user feels something is wrong and changes the password. With plain JWT, the hacker token still works, because the token does not care about the password.

The fix is a timestamp on the user. Call it credentialsChangedAt (the original post called it changeCredentials).

The rule is simple:

If the token was created before the user last changed their password, the token is dead.

Analogy: the festival announces "we changed the wristband color to green at 2pm. Any blue wristband is now invalid." You do not need to hunt down every blue wristband one by one. One announcement kills them all at once.

// inside your auth middleware, after verifying the token signature
if (
  user.credentialsChangedAt &&
  decoded.iat * 1000 < user.credentialsChangedAt.getTime()
) {
  return res.status(401).json({ message: "Token no longer valid, please log in again" });
}
Enter fullscreen mode Exit fullscreen mode

iat is "issued at", a standard field JWT puts in automatically that says when the token was created.

This one timestamp gives you a powerful "log out everywhere" button for free. Change password, all old sessions die.

Level up: refresh token rotation and theft detection

This part was not in the original post, but it is the modern best practice and it is worth knowing.

Right now our refresh token (the ID card) lives for many days. If someone steals it, they can keep getting fresh wristbands for days. Not good.

Refresh token rotation means: every time you use the refresh token to get a new access token, you also get a brand new refresh token, and the old one is thrown away. One time use only.

Analogy: every time you use your ID card at the front desk, they shred it and hand you a new ID card. The old card number is now dead.

Now the clever part, reuse detection (theft detection):

If an old, already used refresh token shows up again, that is a huge red flag. It means two people have the card: the real user and a thief. A normal user never reuses an old card, because they always have the newest one.

So when the server sees a used refresh token come back, it assumes theft and kills the entire token family for that login. Both the real user and the thief are logged out. The user logs in again, the thief is locked out.

Analogy: if security sees a shredded ID card number being used at the door, they lock the whole account and call you to confirm it is really you. Annoying for a second, much safer overall.

Quick lifetime guide that the big providers recommend:

Token Lifetime Why
Access token 5 to 15 minutes Small damage window if stolen
Refresh token (sensitive apps) 7 to 30 days Balance of safety and not annoying the user
Refresh token (single page web app) up to 24 hours Browsers are more exposed, keep it short

Where do you store these tokens in the browser?

This part trips up almost every junior, so read slowly.

localStorage: easy to use, but any JavaScript on your page can read it. If an attacker sneaks in a script (an XSS attack), they read your token instantly. OWASP openly says do not keep session tokens in localStorage.

httpOnly cookie: JavaScript cannot read this cookie at all. Even if an attacker runs a script on your page, they cannot read the token out of it. The trade off is you must protect against CSRF (use the SameSite cookie setting and CSRF tokens).

The recommended hybrid setup today:

  • Access token: keep it in memory only (a variable in your app state). It is short lived, so losing it on refresh of the page is fine, you just silently get a new one.
  • Refresh token: store it in a secure, httpOnly cookie. Scripts cannot touch it, and it is the long lived secret you most want to protect.

Analogy: you keep the cheap day pass (access token) in your pocket where it is easy to grab. You keep your passport (refresh token) in the hotel safe where no random person can reach it.

Putting the whole flow together

Login

  1. Check email and password.
  2. Create an access token (15 min) with a unique jti.
  3. Create a refresh token (store it server side or as a signed token with its own jti).
  4. Send the access token to be kept in memory, and the refresh token in an httpOnly cookie.

Normal request (load dashboard, etc)

  1. Verify the access token signature. Fast. No database.
  2. Check the credentialsChangedAt rule.
  3. Done. This is the stateless fast path.

Access token expired

  1. Browser calls the refresh endpoint. The httpOnly cookie is sent automatically.
  2. Server checks: is this refresh jti on the blacklist? Was it already used (reuse detection)?
  3. If all good, issue a new access token and a new refresh token, retire the old refresh token.

Logout

  1. Add the refresh token jti (and optionally the current access token jti) to the blacklist with an expiresAt.
  2. Clear the cookie.
  3. The access token dies on its own in a few minutes. The refresh token is already banned. User is fully out.

Password change

  1. Update credentialsChangedAt = now.
  2. Every token created before that instant is dead everywhere, automatically.

Here is a compact logout and refresh example:

// LOGOUT
app.post("/logout", auth, async (req, res) => {
  const { jti, exp } = req.user; // from the verified token

  await BlacklistedToken.create({
    jti,
    expiresAt: new Date(exp * 1000), // matches token expiry, TTL cleans it later
  });

  res.clearCookie("refreshToken");
  res.json({ message: "Logged out" });
});

// REFRESH
app.post("/refresh", async (req, res) => {
  const token = req.cookies.refreshToken;
  if (!token) return res.status(401).json({ message: "No refresh token" });

  let decoded;
  try {
    decoded = jwt.verify(token, process.env.REFRESH_SECRET);
  } catch {
    return res.status(401).json({ message: "Invalid refresh token" });
  }

  // is this refresh token banned (logged out or reused)?
  const banned = await BlacklistedToken.findOne({ jti: decoded.jti });
  if (banned) return res.status(401).json({ message: "Token revoked" });

  const user = await User.findById(decoded.sub);
  if (!user) return res.status(401).json({ message: "User not found" });

  // password changed after this token was made? kill it
  if (
    user.credentialsChangedAt &&
    decoded.iat * 1000 < user.credentialsChangedAt.getTime()
  ) {
    return res.status(401).json({ message: "Please log in again" });
  }

  // rotation: ban the old refresh token, issue fresh ones
  await BlacklistedToken.create({
    jti: decoded.jti,
    expiresAt: new Date(decoded.exp * 1000),
  });

  const newAccess = createAccessToken(user);
  const newRefresh = createRefreshToken(user); // new jti inside

  res.cookie("refreshToken", newRefresh, {
    httpOnly: true,
    secure: true,
    sameSite: "strict",
  });
  res.json({ accessToken: newAccess });
});
Enter fullscreen mode Exit fullscreen mode

A note on MongoDB vs Redis for the blacklist

The original post used MongoDB with a TTL index, and that is perfectly fine and clean. Many teams use Redis instead because it is an in memory store built for exactly this kind of fast key lookup, and it also supports automatic expiry. If your blacklist gets very hot (checked a lot), Redis is the common choice. If you are early or your traffic is normal, MongoDB with a TTL index is a solid, simple start. Do not over engineer too early.

Common mistakes juniors make

  • Using one long lived token for everything. Always split into short access and longer refresh.
  • Forgetting to revoke the refresh token on logout. If you only handle the access token, the refresh token can still mint new ones. Logout must kill the refresh token.
  • Checking the blacklist on every single request. That kills the stateless speed benefit. Check it at the refresh step and keep access tokens short.
  • Storing tokens in localStorage because the tutorial did. Prefer memory for access and httpOnly cookie for refresh.
  • No credentialsChangedAt. Without it, changing the password does not actually protect a user whose token was already stolen.
  • Letting the blacklist grow forever. Always set a TTL so dead entries clean themselves up.

The one paragraph summary

JWT is fast because the server trusts the token without a database lookup, like a guard glancing at a wristband. The cost of that speed is that logout does not naturally kill a token. You fix it by using a short lived access token plus a longer refresh token, giving every token a unique jti serial number, putting that jti on a blacklist at logout and checking the blacklist only at the refresh step, auto cleaning the blacklist with a TTL index, and adding a credentialsChangedAt timestamp so a password change kills all old tokens at once. Add refresh token rotation with reuse detection and store tokens safely (access in memory, refresh in an httpOnly cookie) and you have a real, production grade auth system.

Authentication is not just login and register. The small security details are what separate a toy project from a real system.

Sources and further reading

Big thanks to Mohamed Kamal for the original post in Node.js Egypt that started this. If this helped you, share it with a junior who is still using one giant token for everything.

Top comments (0)