How to check if refresh token works
Exchange the refresh token, then call a protected route with the access token that came back. curl -X POST /refresh -d refresh_token=... returns 200 and a new token that answers 200 on /me. Then send the old refresh token a second time. Whether it still works is what one successful refresh hides.
Why check this
Run this after any change to the auth service, its token lifetimes, or the store behind the token endpoint, and on staging sign-off when the access token lives for minutes. A suite that logs in once and finishes inside that lifetime never reaches the refresh code path, so the endpoint ships untested and the first long session of the day fails at a step nobody can reproduce.
The failure this prevents is narrower than a refresh that errors. A 200 from the token endpoint proves the endpoint answered. It does not prove the access token in that response opens anything, and it says nothing about the refresh token you spent. Both are separate requests, and both are below.
Prerequisites
- Node 22 or later. Save the file below as
token-demo.js. It has two exchange routes:/refreshreturns the same refresh token,/refresh-rotatingreplaces it and detects a second use. - curl 8 or later, any build.
- RFC 6749 section 6 for the refresh grant and RFC 9700 section 4.14 for rotation. Section 2.2.2 there requires a public client's refresh token to be sender-constrained or rotated.
- Decode a JWT to read an access token from these responses 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 the pair.
teewrites the response to a file later steps read.curl -s -X POST http://127.0.0.1:8971/login -d 'ttl=300' | tee login.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('refresh_expires_in ' + j.refresh_expires_in); console.log('access jti ' + c.jti); console.log('refresh_token ' + j.refresh_token); }); "expires_in 300 refresh_expires_in 3600 access jti 019cdf25-dd02-4738-99f9-c048e0850798 refresh_token a7119a9a45eb4d20c595c024337e3fd5afcf0be3487f49b1The access token is a JWT with its own
exp. The refresh token is 48 hex characters with no structure, sorefresh_expires_inis the only published figure for its lifetime. - Step 2.
Exchange the refresh token and read what came back.
curl -s -X POST http://127.0.0.1:8971/refresh -d "refresh_token=$(node -p "require('./login.json').refresh_token")" | tee refresh1.json | node -e " let s = ''; process.stdin.on('data', (d) => { s += d; }).on('end', () => { const j = JSON.parse(s); console.log('rotated ' + j.rotated); console.log('expires_in ' + j.expires_in); console.log('refresh_token ' + j.refresh_token); }); "rotated false expires_in 300 refresh_token a7119a9a45eb4d20c595c024337e3fd5afcf0be3487f49b1The refresh token in the response is the string from step 1, character for character. This route does not rotate.
- Step 3.
Spend the new access token on a protected route.
curl -s -H "Authorization: Bearer $(node -p "require('./refresh1.json').access_token")" -w '\nstatus %{http_code}\n' http://127.0.0.1:8971/me{"sub":"alice","jti":"e5b328bd-ba7e-4d9b-99e4-05ed4202a7c8","exp":1789159137} status 200The
jtidiffers from step 1, so this is a new token and not the old one echoed back. Comparejti, notexp: the exchange finished inside the same second, soexpmoved by one second and looks unchanged. - Step 4.
Send the refresh token from step 1 again. This is the assertion most refresh tests do not make.
curl -s -X POST http://127.0.0.1:8971/refresh -d "refresh_token=$(node -p "require('./login.json').refresh_token")" | node -e " let s = ''; process.stdin.on('data', (d) => { s += d; }).on('end', () => { const j = JSON.parse(s); console.log('rotated ' + j.rotated); console.log('unchanged ' + (j.refresh_token === require('./login.json').refresh_token)); }); "rotated false unchanged trueOne refresh token, spent twice, accepted twice. Anyone holding a copy of that string mints access tokens for the rest of its hour, and the real client sees nothing.
- Step 5.
Start a clean session for the rotating route.
curl -s -X POST http://127.0.0.1:8971/login -d 'ttl=300' -o login2.json -w 'login %{http_code}\n'login 200 - Step 6.
Exchange once on
/refresh-rotating.curl -s -X POST http://127.0.0.1:8971/refresh-rotating -d "refresh_token=$(node -p "require('./login2.json').refresh_token")" | tee rot1.json | node -e " let s = ''; process.stdin.on('data', (d) => { s += d; }).on('end', () => { const j = JSON.parse(s); console.log('rotated ' + j.rotated); console.log('refresh_token ' + j.refresh_token); }); "rotated true refresh_token 3b4c596f176cc3c2d90bccdacebf0efa2ed1f0e1300316faA different string. Both routes answered
200on the first exchange, which is why one successful refresh cannot tell them apart. - Step 7.
Replay the spent token against the same route.
curl -s -X POST http://127.0.0.1:8971/refresh-rotating -d "refresh_token=$(node -p "require('./login2.json').refresh_token")" -w '\nstatus %{http_code}\n'{"error":"invalid_grant","error_description":"Refresh token reuse detected, family revoked"} status 400400 invalid_grantis the correct answer for a spent grant. RFC 6749 section 5.2 puts failures of the token endpoint at400, not401. - Step 8.
Now use the token issued one exchange ago, valid a moment before.
curl -s -X POST http://127.0.0.1:8971/refresh-rotating -d "refresh_token=$(node -p "require('./rot1.json').refresh_token")" -w '\nstatus %{http_code}\n'{"error":"invalid_grant","error_description":"Unknown refresh token"} status 400Reuse detection revoked the family, so the good token died with the replayed one. That is the design working, and it is why a rotating server signs a user out after a network retry. Test the retry path before filing a defect.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| 200 and a working access token in step 3 | The refresh grant is wired correctly | Continue to step 4. The endpoint is fine, the token lifecycle is not yet proven. |
| 200 in step 3, 401 when the token is used | The endpoint mints tokens the resource server rejects | Compare the signing key, the issuer and the audience between the two services. |
| Step 4 returns 200 with the same refresh token | No rotation | Report against RFC 9700 section 2.2.2 for a public client. For a confidential client, record it as a risk and check how the token is stored. |
| Step 4 returns 400 invalid_grant | The token rotated silently and the client kept the old one | Read the first response again. The new token was in it and the client discarded it. |
| Step 7 returns 200 | Rotation without reuse detection | A stolen token keeps working next to the real one. Ask for family revocation. |
| Step 8 returns 400 after step 7 | Reuse detection revoked the family | Correct. Check what a retried request does, since a duplicate exchange ends the session. |
Common mistakes
What to check next
- How to test expired token: what the resource server says once the refreshed token runs out.
- How to test logout invalidates token: whether logout ends the refresh chain or leaves it minting tokens.
- How to check JWT expiration: reading
expto know when the next exchange is due. - How to check authorization header: the header format the refreshed token arrives in.
- How to test API authentication: proving the protected route enforces anything at all.
FAQ
How do I test a refresh token?
Three requests, not one. Exchange it, use the access token that came back, then send the refresh token a second time. Steps 2 to 4 do that. The third request is the one that finds bugs.
How do I check refresh token expiration?
You cannot read it. A refresh token is usually opaque, so the only published figure is refresh_expires_in, 3600 in step 1. When the server omits it, measure the lifetime by exchanging, waiting, and exchanging again.
How do I test refresh token rotation?
Compare the refresh token in the response with the one you sent. Step 2 returns the same string, step 6 a different one. Then replay the spent token: rotation without reuse detection answers 200, step 7 shows detection.
Why do access tokens expire?
Because a stateless token cannot be recalled. Between logout and expiry the server has no say, so the lifetime is the window an attacker gets. A short expires_in shrinks it, and the refresh token pays the cost of re-issuing.
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