The fix for an irrevocable token is to make it short-lived and pair it with a long-lived refresh token that is opaque, stored server-side, and therefore deletable. The access token buys speed, the refresh token buys control.

const RT_COOKIE = { httpOnly: true, secure: true, sameSite: 'strict',
path: '/auth/refresh', maxAge: 30 * 864e5 };
app.post('/auth/refresh', async (req, res) => {
const rec = await tokens.findByHash(sha256(req.cookies.rt ?? ''));
if (!rec || rec.expiresAt < Date.now()) {
return res.status(401).json({ error: 'invalid_refresh_token' });
}
if (rec.usedAt) { // a replay: the family is compromised
await tokens.revokeFamily(rec.familyId);
return res.status(401).json({ error: 'token_reuse_detected' });
}
await tokens.markUsed(rec.id);
const user = await users.byId(rec.userId);
res.cookie('rt', await tokens.issue(user, rec.familyId), RT_COOKIE)
.json({ access_token: issueAccess(user), expires_in: 900 });
});Store a hash of the refresh token, not the token: that table is a list of live credentials, and Hashes and HMACs's argument for hashing applies unchanged. Scoping the cookie to path: '/auth/refresh' keeps the browser from attaching it to ordinary API calls. As for the access token in a browser, localStorage is readable by any script on the page, so one XSS gives it away; keep both tokens in httpOnly cookies and pay the CSRF price (CSRF Protection After csurf). Native clients are where a header-carried token is simpler.
Logout has three parts, and skipping one leaves a working credential behind: delete the refresh record server-side, clear the cookies, and accept that outstanding access tokens stay valid until they expire. If minutes are too long, keep a deny-list of jti values in Redis 2,763 with a TTL equal to the access token lifetime.