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

// 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 the pair. tee writes 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      a7119a9a45eb4d20c595c024337e3fd5afcf0be3487f49b1

    The access token is a JWT with its own exp. The refresh token is 48 hex characters with no structure, so refresh_expires_in is the only published figure for its lifetime.

  2. 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 a7119a9a45eb4d20c595c024337e3fd5afcf0be3487f49b1

    The refresh token in the response is the string from step 1, character for character. This route does not rotate.

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

    The jti differs from step 1, so this is a new token and not the old one echoed back. Compare jti, not exp: the exchange finished inside the same second, so exp moved by one second and looks unchanged.

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

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

  5. 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
  6. 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 3b4c596f176cc3c2d90bccdacebf0efa2ed1f0e1300316fa

    A different string. Both routes answered 200 on the first exchange, which is why one successful refresh cannot tell them apart.

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

    400 invalid_grant is the correct answer for a spent grant. RFC 6749 section 5.2 puts failures of the token endpoint at 400, not 401.

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

    Reuse 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

Sign: The refresh test asserts on the status code of the token endpoint and passes.Cause: Step 2 and step 6 both return 200 on servers that behave completely differently. The status proves the grant was accepted. The behaviour of the spent token, in step 4 and step 7, is what separates them.
Sign: A rotating server signs users out at random, always after a slow network.Cause: The client retried the exchange, the second attempt carried the same refresh token, and reuse detection killed the family. Step 8 reproduces it. Retries around the token endpoint have to be idempotent or absent, not merely rare.
Sign: The suite reports the refresh works because the response contains an access_token field.Cause: The field can hold the previous token echoed back. Decode it and compare jti, as in step 3. Comparing exp does not work when the exchange completes inside the same second.
Sign: Nobody can say how long the refresh token lives.Cause: It is opaque, so there is nothing to decode. The only evidence is refresh_expires_in in the login response, and many servers omit it. Measure it instead: exchange once, wait, exchange again, and record the point at which the answer turns into 400.

What to check next

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.

intermediate9 minpublished updated Maks Verny