Skip to content

JWT stays valid after logout

CWE-613CWE-384OWASP A07:2021Updated September 4, 20265 min read

A JSON Web Token is checked on its signature and not looked up in a database. It therefore stays valid until its expiry, even after the user has logged out, changed their password or been blocked. Short lifetimes, refresh tokens and a revocation list restore control.

JSON Web Tokens owe their popularity to one property: the server does not have to look anything up. The signature is valid or it is not, and that is enough. That same property makes it hard to invalidate a token before its expiry, which is a problem at precisely the moments when it matters most. There is a clean way to solve that.

Why is a JWT hard to revoke?

With classic session handling the server keeps a list of valid sessions. Logging out then means: strike that line through. With a JSON Web Token that list does not exist. The token carries the data itself, who the user is, what rights they hold and until when it is valid, and it is signed with a key belonging to the server. On every request the server checks only that signature and the expiry.

That is a deliberate design choice with a clear benefit: no shared session store is needed, which simplifies scaling. But it also means the server has no place to note “this one is no longer valid”. As long as the signature is correct and the expiry has not been reached, the token is valid, regardless of what has happened to the account in the meantime.

It resembles a ticket with a date printed on it rather than a pass that gets scanned. The doorkeeper checks whether it is genuine and whether the date is right; they have no way of knowing that this particular ticket was withdrawn this morning.

How is a JWT that cannot be revoked exploited?

Vulnerable:

// Token with a generous lifetime, no form of revocation at all
function makeToken(user) {
  return jwt.sign(
    { sub: user.id, role: user.role },
    KEY,
    { expiresIn: '24h' },
  );
}

app.post('/logout', (req, res) => {
  res.clearCookie('token');        // the browser only
  res.send('Logged out');
});

function verify(req, res, next) {
  req.user = jwt.verify(req.cookies.token, KEY);
  next();                          // signature is valid, so access
}

Logging out removes the token from the browser, but the token itself stays valid for twenty-four hours. Anyone holding a copy, from a log file, through cross-site scripting, or from a shared computer, gets in with it for the rest of the day. That applies undiminished after a password change, and even after the account has been blocked or deleted: the token carries its own rights and is never checked against reality.

Safe:

// Short access token with a unique identifier, plus a revocable refresh token
function makeAccessToken(user) {
  return jwt.sign(
    { sub: user.id, role: user.role, jti: randomUUID() },
    KEY,
    { expiresIn: '10m' },
  );
}

app.post('/logout', async (req, res) => {
  const payload = jwt.verify(req.cookies.token, KEY);

  // Remember only until the original expiry; after that it is redundant
  const remaining = payload.exp - Math.floor(Date.now() / 1000);
  await cache.set(`revoked:${payload.jti}`, '1', { EX: remaining });
  await refreshTokens.remove(req.cookies.refresh);

  res.clearCookie('token');
  res.clearCookie('refresh');
  res.send('Logged out');
});

async function verify(req, res, next) {
  const payload = jwt.verify(req.cookies.token, KEY);

  if (await cache.get(`revoked:${payload.jti}`)) {
    return res.status(401).send('Token revoked');
  }
  if (payload.iat < await users.tokensInvalidBefore(payload.sub)) {
    return res.status(401).send('Please log in again');
  }

  req.user = payload;
  next();
}

Three things happen here. The access token is valid for only ten minutes, which keeps the window for abuse small. Every token gets a unique identifier through jti, so it can be revoked individually. And there is a per-user timestamp: by setting tokensInvalidBefore to now on a password change, all tokens issued before that moment fall at once, without you having to know them separately.

Do not store tokens in localStorage. That storage is readable by JavaScript, which turns any cross-site scripting flaw directly into a stolen token, and with a token you cannot revoke that is a long-lasting problem. Use a cookie with HttpOnly, Secure and SameSite.

What is the impact of a JWT that cannot be revoked?

The severity runs from medium to high, depending mainly on the lifetime. With tokens of a few minutes the window is small; with tokens lasting a day or more the risk is considerable.

The problem is that logging out and changing a password are the actions with which a user or administrator regains control. If someone suspects their account is compromised, they expect changing their password to shut the intruder out. With a non-revocable token that is not so: the attacker keeps their access until the token expires by itself.

It becomes sharper still when employment ends or rights are withdrawn. An employee whose account is blocked in the morning can keep working the rest of the day with a valid token as though nothing had happened. If the token also carries the role, their old rights remain intact after those have been taken away. That is precisely the scenario an audit tests for.

How do you detect a JWT that cannot be revoked?

A tester logs in, records the token, logs out and uses the token again on a protected route. If it is accepted, there is no revocation. The same test is repeated after a password change, after rights are withdrawn and after the account is blocked; those three go wrong more often in practice than the logout itself.

The contents of the token are examined as well. How long is the lifetime? Does it contain a jti that allows targeted revocation? Does the token carry roles or rights that are thereby frozen until expiry? Are refresh tokens used, and are they rotated on use? Testers also check where the token is stored, because storage in localStorage considerably increases the risk of a non-revocable token. AssistSec assesses these points together, because a short lifetime without revocation is sometimes acceptable while a long lifetime without revocation almost never is.

How do you prevent a JWT that cannot be revoked?

  • Give access tokens a short lifetime, on the order of five to fifteen minutes.
  • Use refresh tokens that you store on the server and can therefore revoke.
  • Rotate refresh tokens on every use and revoke the whole family as soon as an old token is presented again.
  • Give every token a unique jti, so it can be revoked individually.
  • Keep a per-user timestamp before which issued tokens are invalid, and set it on password and rights changes.
  • Store tokens in cookies with HttpOnly, Secure and SameSite, not in localStorage.
  • Do not put roles or rights in the token that can change during its lifetime; look those up instead.
  • Keep the revocation list small by letting revoked identifiers expire at their original expiry time.

Sources

Frequently asked questions

Do I lose the benefits of JWT with a revocation list?

Partly, but less than feared. You do not have to look up every token in a database; it is enough to consult a short list of revoked identifiers, for instance in an in-memory cache. Because that list only contains tokens that have not yet expired, it stays small.

How long may an access token be valid?

Five to fifteen minutes is common. That window is short enough to limit the damage of a stolen token and long enough to keep the number of renewals manageable. The longer validity moves to the refresh token, which you can revoke.

What is token rotation with refresh tokens?

On every renewal the server issues a new refresh token and invalidates the old one. If an old token is later presented anyway, that is a strong signal someone holds a copy; the server then revokes the whole token family. Theft is thereby both limited and detectable.

Can I not simply delete tokens in the browser?

That is exactly the trap. Removing the token from browser storage leaves the token itself untouched: it is still correctly signed and not yet expired. Anyone holding a copy keeps getting in with it. Revocation has to happen on the server.

Related articles

Press / to search · Esc