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
- Node 22 or later. Save the file below as
token-demo.js./logintakes attlin seconds, and the three/meroutes differ only in how much of the refusal they report. - curl 8 or later, any build.
- RFC 6750 section 3 for the
WWW-Authenticateheader and the error attributes, and RFC 7519 section 4.1.4 forexp. - Decode a JWT to read
expfrom a token 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.
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 - 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 - Step 3.
Wait past
expand 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"}401plusWWW-Authenticateis the shape RFC 6750 section 3 asks for. The error code isinvalid_tokenand nothing in it says "expired", so the description carries the whole meaning. - 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 sTwo seconds past
expwas enough. This server applies no leeway, so record the measured margin rather than assuming a grace window exists. - 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 - 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.
- 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 401Three 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.
- 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"; doneexpired The access token expired bad_signature Signature verification failed malformed The access token is not a JWTThis 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
What to check next
- How to check JWT expiration: reading
expand predicting the verdict before you send the token. - How to check if refresh token works: the call a client should make after the
401in step 3. - How to test logout invalidates token: the other reason a valid-looking token stops working.
- How to check authorization header: the header these steps send, and how it fails when the scheme is wrong.
- How to test API authentication: confirming the route verifies tokens at all.
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.
Related on this site
intermediate7 minpublished updated Maks Verny