How to test expired token

Issue a token with a two second lifetime, wait past exp, then send it again. The server answers 401 with a WWW-Authenticate header naming invalid_token. Then send a token signed with the wrong key. If both refusals are identical, the client cannot tell an expired token from one that was never valid.

Why check this

Run this on staging sign-off, after any change to token lifetimes, and whenever a client reports being signed out at random. The failure it prevents is a resource server that keeps accepting tokens past exp, which turns a five minute token into an unbounded one and makes every lifetime in the configuration decorative.

The second half matters more in support. RFC 6750 section 3.1 defines one error code, invalid_token, for a token that is "expired, revoked, malformed, or invalid for other reasons". Everything the client needs to choose between refreshing and re-authenticating sits in error_description, which the same document marks optional and aimed at developers. A server that omits it forces every client to guess, and the usual guess is an infinite refresh loop.

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.

    Issue a token with a two second life. A short lifetime turns an expiry test into something a suite can run.

    curl -s -X POST http://127.0.0.1:8971/login -d 'ttl=2' | tee short.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('expires_in ' + j.expires_in);
      console.log('iat        ' + c.iat);
      console.log('exp        ' + c.exp + '  = iat + ' + (c.exp - c.iat) + ' s');
    });
    "
    
    expires_in 2
    iat        1789158890
    exp        1789158892  = iat + 2 s
  2. Step 2.

    Use it at once, so a later refusal cannot be blamed on a bad token.

    curl -s -H "Authorization: Bearer $(node -p "require('./short.json').access_token")" -w '\nstatus %{http_code}\n' http://127.0.0.1:8971/me
    
    {"sub":"alice","jti":"92e8add3-41cb-4494-bf94-3a4f596dbbff","exp":1789158892}
    
    status 200
  3. Step 3.

    Wait past exp and send the same token again. Read the headers, not only the body.

    sleep 3 && curl -s -i -H "Authorization: Bearer $(node -p "require('./short.json').access_token")" http://127.0.0.1:8971/me | grep -iE '^HTTP|^www-authenticate|error'
    
    HTTP/1.1 401 Unauthorized
    www-authenticate: Bearer error="invalid_token", error_description="The access token expired"
    {"error":"invalid_token","error_description":"The access token expired","reason":"expired"}

    401 plus WWW-Authenticate is the shape RFC 6750 section 3 asks for. The error code is invalid_token and nothing in it says "expired", so the description carries the whole meaning.

  4. Step 4.

    Confirm the clock, not the endpoint, produced that answer.

    node -p "const c = JSON.parse(Buffer.from(require('./short.json').access_token.split('.')[1], 'base64url')); 'exp       ' + c.exp + '\nnow       ' + Math.floor(Date.now() / 1000) + '\nexp - now ' + (c.exp - Math.floor(Date.now() / 1000)) + ' s'"
    
    exp       1789158892
    now       1789158894
    exp - now -2 s

    Two seconds past exp was enough. This server applies no leeway, so record the measured margin rather than assuming a grace window exists.

  5. Step 5.

    Build a token that was never valid: correct structure, unexpired exp, wrong signing key.

    node -e "
    const c = require('node:crypto');
    const b = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
    const now = Math.floor(Date.now() / 1000);
    const d = b({ alg: 'HS256', typ: 'JWT' }) + '.' + b({ sub: 'alice', jti: c.randomUUID(), iat: now, exp: now + 3600 });
    const t = d + '.' + c.createHmac('sha256', 'wrong-key').update(d).digest('base64url');
    require('node:fs').writeFileSync('forged.txt', t);
    const claims = JSON.parse(Buffer.from(t.split('.')[1], 'base64url'));
    console.log('exp - now ' + (claims.exp - now) + ' s');
    console.log('parts     ' + t.split('.').length);
    "
    
    exp - now 3600 s
    parts     3
  6. Step 6.

    Send the forged token to the same route.

    curl -s -i -H "Authorization: Bearer $(cat forged.txt)" http://127.0.0.1:8971/me | grep -iE '^HTTP|^www-authenticate|error'
    
    HTTP/1.1 401 Unauthorized
    www-authenticate: Bearer error="invalid_token", error_description="Signature verification failed"
    {"error":"invalid_token","error_description":"Signature verification failed","reason":"bad_signature"}

    Same status, same error code, different description. The description is the only field that moved.

  7. Step 7.

    Send the expired token, the forged token and a string that is not a token to a server that reports one refusal for all of them.

    for T in "$(node -p "require('./short.json').access_token")" "$(cat forged.txt)" xyz; do curl -s -H "Authorization: Bearer $T" -w 'status %{http_code}\n' http://127.0.0.1:8971/me-flat; done
    
    {"error":"unauthorized"}
    status 401
    {"error":"unauthorized"}
    status 401
    {"error":"unauthorized"}
    status 401

    Three different causes, one answer, byte for byte. A client here cannot decide between refreshing and sending the user back to the login form, so most implementations refresh, fail again, and loop.

  8. Step 8.

    Send the same three to the route that separates them, and read the reason.

    for T in "$(node -p "require('./short.json').access_token")" "$(cat forged.txt)" xyz; do curl -s -H "Authorization: Bearer $T" http://127.0.0.1:8971/me | node -p "const j = JSON.parse(require('node:fs').readFileSync(0, 'utf8')); j.reason.padEnd(14) + j.error_description"; done
    
    expired       The access token expired
    bad_signature Signature verification failed
    malformed     The access token is not a JWT

    This is the behaviour to ask for. Only the first line should send a client to the refresh endpoint.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | 401 with error="invalid_token" after exp | The expiry is enforced | Record the measured margin from step 4 alongside it. | | 200 after exp | The resource server ignores exp | Report it. Every configured lifetime on the service is inert. | | 401 for expired and forged with identical bodies | The cause is hidden from the client | Ask for error_description, as in step 8. Without it the client cannot choose its next call. | | 403 instead of 401 | The wrong code | RFC 6750 reserves 403 for insufficient_scope. An expired token is 401. | | 500 on the expired token | The verifier throws instead of refusing | A failed verification is an expected path, not an exception. Report the stack trace as a defect. | | 200 for a few seconds after exp | A leeway window is configured | Measure it and get the number into the test plan, since clients will rely on it. |

Common mistakes

Sign: The expiry test edits exp in the payload and sends the result.Cause: That changes the signature check, not the expiry check. The server answers bad_signature, as in step 6, and the test passes on a server that never looks at exp. Ask the issuer for a short lifetime instead, as in step 1.
Sign: A client refreshes in a tight loop and hammers the token endpoint.Cause: It received 401 invalid_token for a malformed or forged token and read it as expired, because the server sent no error_description. Step 7 is that server. RFC 6750 section 3.1 gives all three causes one error code, so the description is the only place the difference can live.
Sign: The suite waits for the token to expire by sleeping for the full production lifetime.Cause: Nothing verifies expiry faster than an issuer that accepts a lifetime parameter. Ask for one on the test build. A suite that sleeps 15 minutes gets deleted from CI, and the check disappears with it.
Sign: An expired token is refused by the API and accepted by one endpoint behind the same gateway.Cause: Verification happens per service, not per token. Repeat step 3 against every service that reads the token, including any that caches verification results.

What to check next

FAQ

How do I identify if a token has expired?

Ask the server. Step 3 sends the token and reads error_description, which is the verifier's own verdict. Decoding exp locally, as in step 4, predicts that verdict using your clock, and the two disagree whenever the clocks do.

How do I check token expiration?

Decode the payload and compare exp with the current Unix time, which is the whole of step 4. For a token you cannot decode, send it and read the status. An opaque token has no readable expiry at all.

How do I check if an expired token is still accepted?

Steps 1 to 3. Issue a short-lived token, prove it works, wait past exp, send it again. A 200 on the second call means the server ignores exp, and the lifetime in the configuration means nothing.

Should an expired token return 401 or 403?

401. RFC 6750 section 3.1 pairs invalid_token with 401 and reserves 403 for insufficient_scope, which is a permission problem rather than an authentication one. A 403 for expiry sends the client to the wrong recovery path.

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.

intermediate7 minpublished updated Maks Verny