DEV Community

Anas Sheikh
Anas Sheikh

Posted on

Your JWT-Based Auth Might Let a Stolen Token Outlive a Password Reset

I've written before about JWT in an httpOnly cookie as a solid auth pattern, and I stand by it, it closes off the most common token-theft path through XSS. There's a real, separate limitation worth being honest about though, one that's easy to overlook specifically because the pattern otherwise feels so solid.

The Core Tradeoff of Stateless Auth

A JWT's whole appeal is that verifying it doesn't require a database lookup, you check the signature, confirm it hasn't expired, and trust the payload. This is fast and simple, and it's also the exact reason a JWT can't be individually revoked once issued. There's no database record being checked and deleted, there's just a cryptographic signature that remains valid until its built-in expiration time arrives, regardless of anything that happens to the account in the meantime.

What This Actually Means in Practice

// lib/auth.ts
export function signToken(payload: SessionPayload): string {
  return jwt.sign(payload, JWT_SECRET, { expiresIn: '7d' });
}

export function verifyToken(token: string): SessionPayload | null {
  try {
    return jwt.verify(token, JWT_SECRET) as SessionPayload;
    // If this succeeds, the token is treated as valid. Nothing here checks
    // whether the account's password changed, or whether this specific
    // token was ever explicitly revoked.
  } catch {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

With a 7-day expiration, a token issued today remains fully valid for the next 7 days, even if the account owner changes their password tomorrow, even if an admin bans the account the day after, even if the user explicitly logs out from every device they can find. If that token was ever stolen, copied from an insecure connection, extracted through some other compromise, "changing your password" does nothing to invalidate it. The stolen copy is still a completely valid credential until its own expiration clock runs out, independent of anything the actual account owner does.

Why This Surprises People

Most developers' mental model of "logging out" or "changing your password" implicitly assumes it invalidates existing sessions, because that's genuinely how session-based auth, the older, database-backed pattern, actually works, deleting a session record makes every reference to that session immediately worthless. JWT-based auth doesn't automatically inherit that property, and the pattern's real strength, avoiding a database check on every single request, is precisely what removes the natural point where revocation would happen.

The Actual Fix: A Deliberate Revocation Layer on Top

You don't have to abandon JWTs to fix this, you need one explicit, lightweight database check for a specific category of security-critical events, not on every single request.

// models/User.ts
const UserSchema = new Schema({
  // ...
  passwordChangedAt: Date, // updated whenever a password change happens
  tokensRevokedAt: Date,   // updated on "log out everywhere" or an admin-forced ban
});
Enter fullscreen mode Exit fullscreen mode
// lib/auth.ts
export async function getSession(): Promise<SessionPayload | null> {
  const token = /* read from cookie */;
  const decoded = verifyToken(token);
  if (!decoded) return null;

  // One targeted check, not a full session lookup on every request
  const user = await User.findById(decoded.userId).select('passwordChangedAt tokensRevokedAt');
  if (!user) return null;

  const tokenIssuedAt = decoded.iat * 1000; // JWT 'iat' claim, in seconds

  if (user.passwordChangedAt && tokenIssuedAt < user.passwordChangedAt.getTime()) {
    return null; // token was issued before the most recent password change, reject it
  }

  if (user.tokensRevokedAt && tokenIssuedAt < user.tokensRevokedAt.getTime()) {
    return null; // explicitly revoked, e.g. "log out everywhere" or an admin action
  }

  return decoded;
}
Enter fullscreen mode Exit fullscreen mode

This does add a database read back into session verification, which is a real, deliberate tradeoff against the pure-stateless approach's speed advantage. It's a reasonable one for most applications, since the alternative is a genuinely unbounded window where a compromised token remains fully valid no matter what the actual account owner does in response.

A Simpler Partial Mitigation: Just Shorten the Expiration

If a full revocation check feels like more infrastructure than a given project needs, the single cheapest mitigation is simply reducing how long a token stays valid in the first place, paired with a refresh token pattern to avoid forcing frequent re-logins.

// A short-lived access token, refreshed frequently
export function signAccessToken(payload: SessionPayload): string {
  return jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
}

// A longer-lived refresh token, itself revocable since it IS checked against the database
export function signRefreshToken(payload: SessionPayload): string {
  return jwt.sign(payload, REFRESH_SECRET, { expiresIn: '7d' });
}
Enter fullscreen mode Exit fullscreen mode

This shrinks the exposure window for a stolen access token from potentially a full week down to 15 minutes, while the refresh token, which actually gets checked against the database on each use, provides the real revocation point. It's more moving parts than a single long-lived token, but it directly addresses the actual gap rather than just hoping a token never gets stolen in the first place.

The Honest Tradeoff Summary

Pure stateless JWT, long expiration: fastest, simplest, and genuinely has no way to revoke a specific compromised token before it naturally expires.

JWT with a targeted revocation check (passwordChangedAt, tokensRevokedAt): adds one lightweight database read per session check, closes the actual gap, reasonable default for most real applications.

Short-lived access token plus revocable refresh token: more infrastructure, smallest possible exposure window, the choice for anything genuinely security-sensitive.

None of these are universally "correct," they're a real tradeoff between simplicity and how much exposure window you're comfortable accepting if a token is ever actually stolen.


If your app is running long-lived JWTs with no revocation check at all, worth deciding deliberately whether that's an acceptable tradeoff for what you're building, rather than an assumption nobody ever actually examined. Drop your own approach in the comments, curious how people are actually handling this across real projects.

Get the templates: https://pixelanas.gumroad.com


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (0)