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

// 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

  1. 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 376f6716688c23509b7435fb8d7910f9bc8bf56899b11953

    Keep that jti. It is how you recognise the same token later.

  2. 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
  3. 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 -3
    
    HTTP/1.1 204 No Content
    Date: Fri, 11 Sep 2026 20:35:25 GMT
    Connection: keep-alive

    204 is where most logout tests stop. The response carries no evidence about the token, because there is nothing it could carry.

  4. 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 200

    Same jti as step 1, same account, after a successful logout. The signature still verifies and exp is still ahead, so the route has no reason to refuse. It answers 200 for the remaining 300 seconds.

  5. 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 1789159225

    The 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.

  6. 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-revoke
    
    login 200
    logout-revoke 204

    The same 204 as step 3. The status code says nothing about which of the two handlers you called.

  7. 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
  8. 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 400

    Both halves are closed. A logout that revokes only the access token leaves this call returning 200, which is the most common partial fix.

  9. 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 200

    Revocation 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

Sign: The logout test asserts 204 or 200 from the logout endpoint and passes.Cause: Step 3 and step 6 return the same 204 from handlers that behave differently. The response says what the server did to the client, not what it did to the token.
Sign: The test logs out and then reuses the same client to call a protected route.Cause: That client dropped the token, so the 401 proves nothing about the server. The replay has to come from a copy taken before logout, which is what the file in step 1 holds.
Sign: Logout was fixed, and an incident still shows access continuing after sign-out.Cause: Only the access token was revoked. Step 5 shows the copied refresh token minting a new access token with a full lifetime, minutes after a successful logout.
Sign: A revoked token is refused by the API and accepted by one internal service.Cause: Step 9 reproduces it in a single process. Signature verification is local to each verifier, and a denylist is an extra lookup that a service can be built without. Enumerate the verifiers, not the endpoints.

What to check next

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.

intermediate9 minpublished updated Maks Verny