How to test logout invalidates token
Copy the access token before you log out, then replay the copy. curl -H "Authorization: Bearer <old>" /me returns 200 on a stateless server, because logout cannot un-sign a JWT. Only a route that consults a denylist answers 401. Run the same replay against the refresh token.
Why check this
Run this on every release that touches the logout handler, the token service or the gateway that verifies tokens. It is the token version of How to check if session expires after logout, and the answer is worse. A server-side session can be deleted. A signed token cannot: it stays valid until exp, wherever it is held, unless something checks a list on every request.
The concrete failure is a support case that reads as a mystery. A user reports a compromised account, the team ends the session, and the attacker keeps reading data until the access token expires, then renews with the refresh token nobody revoked. Steps 4 and 5 reproduce both halves.
Prerequisites
- Node 22 or later. Save the file below as
token-demo.js./logoutis the stateless handler,/logout-revokedenylists thejtiand drops the refresh family, and/me-strictis the only route that reads that denylist. - curl 8 or later, any build.
- RFC 7009 for token revocation, and RFC 6750 section 3.1 for the error a revoked token is meant to produce.
- Decode a JWT to read the
jtiandexpof the tokens below without a shell.
// token-demo.js - local target for the token checks. Node 22, no dependencies.
const http = require('node:http');
const crypto = require('node:crypto');
const KEY = 'demo-signing-key';
const PORT = 8971;
const denylist = new Set(); // jti of access tokens killed by logout
const refresh = new Map(); // opaque token -> { user, family, used, exp }
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const sign = (d) => crypto.createHmac('sha256', KEY).update(d).digest('base64url');
const now = () => Math.floor(Date.now() / 1000);
const body = (req) => new Promise((r) => { let s = ''; req.on('data', (d) => { s += d; }); req.on('end', () => r(new URLSearchParams(s))); });
const json = (res, code, obj, extra = {}) => res.writeHead(code, { 'content-type': 'application/json', ...extra }).end(JSON.stringify(obj) + '\n');
function issueAccess(user, ttl) {
const d = b64({ alg: 'HS256', typ: 'JWT' }) + '.' + b64({ sub: user, jti: crypto.randomUUID(), iat: now(), exp: now() + ttl });
return d + '.' + sign(d);
}
function issueRefresh(user, family, ttl) {
const t = crypto.randomBytes(24).toString('hex');
refresh.set(t, { user, family, used: false, exp: now() + ttl });
return t;
}
// Separates the reasons a token is refused. A resource server chooses how many of them to reveal.
function verify(token, useDenylist) {
const p = String(token || '').split('.');
if (p.length !== 3) return { reason: 'malformed' };
if (sign(p[0] + '.' + p[1]) !== p[2]) return { reason: 'bad_signature' };
let c;
try { c = JSON.parse(Buffer.from(p[1], 'base64url').toString('utf8')); } catch { return { reason: 'malformed' }; }
if (useDenylist && denylist.has(c.jti)) return { reason: 'revoked', c };
if (c.exp <= now()) return { reason: 'expired', c };
return { ok: true, c };
}
const bearer = (req) => (/^Bearer\s+(.+)$/i.exec(req.headers.authorization || '') || [])[1];
const DESC = {
expired: 'The access token expired',
bad_signature: 'Signature verification failed',
malformed: 'The access token is not a JWT',
revoked: 'The access token was revoked at logout',
missing: 'An access token is required',
};
http.createServer(async (req, res) => {
const u = new URL(req.url, 'http://127.0.0.1');
const p = u.pathname;
if (p === '/login' && req.method === 'POST') {
const f = await body(req);
const ttl = Number(f.get('ttl') || 300);
const rttl = Number(f.get('rttl') || 3600);
const family = crypto.randomUUID();
return json(res, 200, {
token_type: 'Bearer',
access_token: issueAccess('alice', ttl),
expires_in: ttl,
refresh_token: issueRefresh('alice', family, rttl),
refresh_expires_in: rttl,
});
}
// Three resource servers over one token format.
// /me signature and exp only, reasons reported (stateless, the common shape)
// /me-strict the same plus the logout denylist
// /me-flat one 401 for every failure, no reason (the shape that hides the cause)
if (p === '/me' || p === '/me-strict' || p === '/me-flat') {
const t = bearer(req);
const v = t ? verify(t, p === '/me-strict') : { reason: 'missing' };
if (v.ok) return json(res, 200, { sub: v.c.sub, jti: v.c.jti, exp: v.c.exp });
if (p === '/me-flat') return json(res, 401, { error: 'unauthorized' });
return json(res, 401, { error: 'invalid_token', error_description: DESC[v.reason], reason: v.reason },
{ 'www-authenticate': `Bearer error="invalid_token", error_description="${DESC[v.reason]}"` });
}
// Non-rotating: the refresh token survives the exchange and can be spent again.
if (p === '/refresh' && req.method === 'POST') {
const t = (await body(req)).get('refresh_token');
const r = refresh.get(t);
if (!r) return json(res, 400, { error: 'invalid_grant', error_description: 'Unknown refresh token' });
if (r.exp <= now()) return json(res, 400, { error: 'invalid_grant', error_description: 'Refresh token expired' });
return json(res, 200, { token_type: 'Bearer', access_token: issueAccess(r.user, 300), expires_in: 300, refresh_token: t, rotated: false });
}
// Rotating, with reuse detection: a second exchange of the same token kills the family.
if (p === '/refresh-rotating' && req.method === 'POST') {
const t = (await body(req)).get('refresh_token');
const r = refresh.get(t);
if (!r) return json(res, 400, { error: 'invalid_grant', error_description: 'Unknown refresh token' });
if (r.exp <= now()) return json(res, 400, { error: 'invalid_grant', error_description: 'Refresh token expired' });
if (r.used) {
for (const [k, v] of refresh) if (v.family === r.family) refresh.delete(k);
return json(res, 400, { error: 'invalid_grant', error_description: 'Refresh token reuse detected, family revoked' });
}
r.used = true;
return json(res, 200, { token_type: 'Bearer', access_token: issueAccess(r.user, 300), expires_in: 300, refresh_token: issueRefresh(r.user, r.family, 3600), rotated: true });
}
// Stateless logout: answers the client and leaves the signed token valid.
if (p === '/logout' && req.method === 'POST') return res.writeHead(204).end();
// Logout that revokes: the jti goes on the denylist, the refresh family is dropped.
if (p === '/logout-revoke' && req.method === 'POST') {
const v = verify(bearer(req), false);
if (v.c) denylist.add(v.c.jti);
const t = (await body(req)).get('refresh_token');
const r = refresh.get(t);
if (r) for (const [k, val] of refresh) if (val.family === r.family) refresh.delete(k);
return res.writeHead(204).end();
}
return json(res, 404, { error: 'not_found' });
}).listen(PORT, '127.0.0.1', () => console.log(`token demo on http://127.0.0.1:${PORT} pid ${process.pid}`));
Check the port is free, then start the server and note the process id it prints.
netstat -ano | grep ':8971 ' ; node token-demo.js
token demo on http://127.0.0.1:8971 pid 5944
Stop that id when you finish: taskkill /PID 5944 /F on Windows, kill 5944 elsewhere. Never stop every process named node, which kills other people's servers too.
Steps
- Step 1.
Log in and keep both tokens. The file is the second client, the one logout will never reach.
curl -s -X POST http://127.0.0.1:8971/login -d 'ttl=300' | tee s1.json | node -e " let s = ''; process.stdin.on('data', (d) => { s += d; }).on('end', () => { const j = JSON.parse(s); const c = JSON.parse(Buffer.from(j.access_token.split('.')[1], 'base64url')); console.log('jti ' + c.jti); console.log('exp ' + c.exp + ' = iat + ' + j.expires_in + ' s'); console.log('refresh_token ' + j.refresh_token); }); "jti 8e856158-c2e4-493f-9519-f471f68ac1c6 exp 1789159224 = iat + 300 s refresh_token 376f6716688c23509b7435fb8d7910f9bc8bf56899b11953Keep that
jti. It is how you recognise the same token later. - Step 2.
Confirm the token works before logout, so a later refusal cannot be blamed on the login.
curl -s -H "Authorization: Bearer $(node -p "require('./s1.json').access_token")" -w '\nstatus %{http_code}\n' http://127.0.0.1:8971/me{"sub":"alice","jti":"8e856158-c2e4-493f-9519-f471f68ac1c6","exp":1789159224} status 200 - Step 3.
Log out through the stateless handler and read the response.
curl -s -i -X POST -H "Authorization: Bearer $(node -p "require('./s1.json').access_token")" http://127.0.0.1:8971/logout | head -3HTTP/1.1 204 No Content Date: Fri, 11 Sep 2026 20:35:25 GMT Connection: keep-alive204is where most logout tests stop. The response carries no evidence about the token, because there is nothing it could carry. - Step 4.
Replay the copied access token. This is the assertion the procedure exists for.
curl -s -H "Authorization: Bearer $(node -p "require('./s1.json').access_token")" -w '\nstatus %{http_code}\n' http://127.0.0.1:8971/me{"sub":"alice","jti":"8e856158-c2e4-493f-9519-f471f68ac1c6","exp":1789159224} status 200Same
jtias step 1, same account, after a successful logout. The signature still verifies andexpis still ahead, so the route has no reason to refuse. It answers200for the remaining 300 seconds. - Step 5.
Send the refresh token that was copied with it.
curl -s -X POST http://127.0.0.1:8971/refresh -d "refresh_token=$(node -p "require('./s1.json').refresh_token")" | node -e " let s = ''; process.stdin.on('data', (d) => { s += d; }).on('end', () => { const c = JSON.parse(Buffer.from(JSON.parse(s).access_token.split('.')[1], 'base64url')); console.log('new jti ' + c.jti); console.log('new exp ' + c.exp); }); "new jti aa2af92d-713c-4cff-8c32-c216a795121c new exp 1789159225The holder of the copy now has a new access token with a full 300 seconds, issued after logout. Access does not decay here, it renews.
- Step 6.
Start a new session and log out through the handler that revokes.
curl -s -X POST http://127.0.0.1:8971/login -d 'ttl=300' -o s2.json -w 'login %{http_code}\n' && curl -s -X POST -H "Authorization: Bearer $(node -p "require('./s2.json').access_token")" -d "refresh_token=$(node -p "require('./s2.json').refresh_token")" -o /dev/null -w 'logout-revoke %{http_code}\n' http://127.0.0.1:8971/logout-revokelogin 200 logout-revoke 204The same
204as step 3. The status code says nothing about which of the two handlers you called. - Step 7.
Replay that access token against the route that reads the denylist.
curl -s -H "Authorization: Bearer $(node -p "require('./s2.json').access_token")" -w '\nstatus %{http_code}\n' http://127.0.0.1:8971/me-strict{"error":"invalid_token","error_description":"The access token was revoked at logout","reason":"revoked"} status 401 - Step 8.
Send the refresh token from the same session.
curl -s -X POST http://127.0.0.1:8971/refresh -d "refresh_token=$(node -p "require('./s2.json').refresh_token")" -w '\nstatus %{http_code}\n'{"error":"invalid_grant","error_description":"Unknown refresh token"} status 400Both halves are closed. A logout that revokes only the access token leaves this call returning
200, which is the most common partial fix. - Step 9.
Send the revoked token to a service that verifies signatures and does not read the denylist.
curl -s -H "Authorization: Bearer $(node -p "require('./s2.json').access_token")" -w '\nstatus %{http_code}\n' http://127.0.0.1:8971/me{"sub":"alice","jti":"751b5706-c60b-4852-9bb6-4e2b6d2fbc15","exp":1789159225} status 200Revocation held on one route and not the other, in the same process, for the same token. Revocation is a property of the verifier, so test it on every service that accepts the token.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| 401 on the replayed access token | The verifier consults a revocation list | Repeat step 9 against the other services before closing the ticket. |
| 200 on the replayed access token | The token outlived the logout | Read exp from step 1. That is how long the holder of a copy keeps access. |
| 200 on the replay, 401 after a delay | The token expired, the logout did nothing | Still a defect. Report the window, measured from step 1. |
| Access token refused, refresh token accepted | Half a logout | The holder renews indefinitely. Ask for the refresh family to be dropped, as in step 8. |
| 401 on one service and 200 on another | The denylist is read in one place | Step 9. List every verifier and repeat the replay against each. |
| 401 in test and 200 in production | The denylist is in process memory | It survives one node and not a cluster. Ask where the list is stored. |
Common mistakes
What to check next
- How to check if session expires after logout: the same replay against a server-side session, where the fix is a delete.
- How to check if refresh token works: the exchange in step 5, and what rotation changes about it.
- How to test expired token: the refusal a token produces on its own, with no logout involved.
- How to verify JWT signature: why logout cannot alter a token that is already signed.
- How to test login with curl: where the token pair in step 1 comes from.
FAQ
How do I test a logout API?
Three assertions. The logout call answers, a copy of the access token taken beforehand is refused, and the copied refresh token is refused as well. Steps 3 to 5 show a handler that passes the first and fails the other two.
How do I check if a JWT is still valid after logout?
Replay it from a client the logout never touched, as in step 4. Decoding it is not enough: the signature and exp are unchanged by definition, so only the verifier's answer settles it.
How do I invalidate a JSON Web Token?
You cannot invalidate the token itself. The verifier has to reject it, either from a denylist of jti values kept until exp, as /me-strict does, or by a signing key rotation that invalidates every token at once.
Why does the same token work on one service and fail on another?
Because each service decides on its own. Step 9 shows one process where the route with the denylist refuses the token and the route without it accepts the same string. Every verifier needs the list and the lookup.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate9 minpublished updated Maks Verny