TestingAPIs is still being built. If something breaks, email support@testingapis.com.

Access and refresh tokens

Short-lived tokens, rotation, reuse, and two browser tabs refreshing at once.

The first lessonended on a trade-off. A self-contained token can't easily be taken back, so the longer it lives, the longer a stolen copy works. But a token that expires every few minutes would make users log in every few minutes. Refresh tokens are the usual way out: a short-lived access token for calling the API, and a longer-lived refresh token whose only job is getting new access tokens.

Two tokens with different jobs

Logging in to the JWT test API returns both:

Response
{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
  "tokenType": "Bearer",
  "expiresIn": 900,
  "refreshToken": "jrt_68b839b209f1de60d63969e3b87d18c6ebc5ba…",
  ...
}
Access tokenRefresh token
Sent toEvery API call, in the Authorization headerOnly the refresh endpoint
Lifetime hereexpiresIn: 900, 15 minutesLonger, and single use
Format hereA JWT the API verifies on its ownAn opaque string starting jrt_, looked up on the server
If stolenWorks until it expiresCan mint new access tokens, so it must be guarded more closely

The refresh token is opaque and stored on the server for a reason: the server must be able to cancel it. That puts back the revocation that a stateless access token gave up, and limits the stateless part to 15-minute windows.

Rotation

Each refresh on this API returns a new access token anda new refresh token, and the old refresh token stops working. That's rotation:

Terminal
$ curl -X POST https://api.testingapis.com/api/auth/jwt/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken": "jrt_68b839b2…"}'
{"accessToken":"eyJhbGciOiJIUzI1NiIs…","tokenType":"Bearer","expiresIn":900,"refreshToken":"jrt_0935c88f…", ...}

# The same refresh token a second time
$ curl -i -X POST https://api.testingapis.com/api/auth/jwt/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken": "jrt_68b839b2…"}'
HTTP/1.1 401 Unauthorized

{"status":401,"error":"Unauthorized","message":"Refresh token was already used. Every refresh token from this login has been revoked; log in again."}
This example uses endpoints for signed-in members.Sign inCreate a free account

Rotation means a stolen refresh token is good for one use at most, and only if the thief uses it before the real client does. When the real client next refreshes with a token the thief already spent, it gets a 401, and the theft surfaces. The message above says what this server did about it, which is the subject of the next section.

What a reused token should trigger

A reused refresh token is a strong sign that two parties hold copies of it. The OAuth 2.0 Security Best Current Practice, RFC 9700, recommends that when a server sees one, it revokes the whole chain of tokens descended from the same login, so the thief's copies die too. This server does. After the reuse above was refused, the newest refresh token from the chain, which nobody had used yet, stopped working as well, and both parties have to log in again. Test it on any API with rotation: refresh once, replay the old token, then try the new one. Rotation without reuse detection catches a theft only when the victim happens to refresh second.

One more thing rotation doesn't do: it doesn't cancel the access token you already have. The original access token kept working after the refresh, as JWTs do, until its own expiry. If the product promises "logging out ends access immediately", that promise has a 15-minute gap unless the API also checks a deny list.

Two refreshes at once

Rotation creates a problem for clients. Picture a web app with several requests in flight when the access token expires. All of them get 401, and all of them try to refresh, with the same refresh token. The first refresh succeeds and spends the token. The rest fail. Here it is against the live API, with three callers:

JavaScript
const API = "https://api.testingapis.com/api/auth/jwt";
const post = (path, body) =>
  fetch(API + path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });

let tokens = await (await post("/login", { username: "user", password: "user123" })).json();

// Naive: every caller refreshes on its own.
async function naiveRefresh() {
  const res = await post("/refresh", { refreshToken: tokens.refreshToken });
  if (!res.ok) throw new Error(`refresh failed: ${res.status}`);
  tokens = await res.json();
}

const naive = await Promise.allSettled([1, 2, 3].map(() => naiveRefresh()));
console.log("naive:", naive.map((r) => (r.status === "fulfilled" ? "ok" : r.reason.message)));

// Did the one refresh that worked leave the user signed in?
const after = await post("/refresh", { refreshToken: tokens.refreshToken });
console.log("next refresh:", after.status, (await after.json()).message);
This example uses endpoints for signed-in members.Sign inCreate a free account

Two of the three refreshes got 401, and it's worse than that looks. To the server, the second and third refreshes are a spent token being used again, which is exactly what theft looks like, so it revoked the whole chain, including the new token the first refresh had just returned. The next refresh fails and the user is logged out, although nobody stole anything. Users see this as being logged out at random, usually right after returning to a tab that sat idle long enough for the token to expire. The same race happens across browser tabs that share the stored tokens. Reuse detection is the right server behaviour; it makes a careful client essential.

The fix is to let only one refresh happen at a time and have everyone else wait for it:

JavaScript
// Single flight: callers that arrive while a refresh is running wait for that one.
let refreshing = null;
function sharedRefresh() {
  refreshing ??= post("/refresh", { refreshToken: tokens.refreshToken })
    .then(async (res) => {
      if (!res.ok) throw new Error(`refresh failed: ${res.status}`);
      tokens = await res.json();
    })
    .finally(() => {
      refreshing = null;
    });
  return refreshing;
}
Output
naive: [ 'ok', 'refresh failed: 401', 'refresh failed: 401' ]
next refresh: 401 Refresh token was already used. Every refresh token from this login has been revoked; log in again.

shared: [ 'ok', 'ok', 'ok' ]
next refresh: 200

All three callers succeed with a single refresh, and the user stays signed in. Across tabs, the same idea needs a shared lock, which browsers offer through the Web Locks API, or one tab doing the refreshing and telling the others through a BroadcastChannel. When you test an app with rotating refresh tokens, this is the scenario to stage: let the access token expire, then trigger several requests at once, in one tab and then in two.

The same pattern in OAuth

The OAuth 2.0 test server rotates its refresh tokens the same way, with one difference in how it says no. The OAuth specification, RFC 6749, has its own error format, so reusing a spent refresh token there returns 400 with {"error":"invalid_grant"} rather than 401. Both are correct for their own protocols. A client that handles only one of them will mishandle the other, which matters when an app talks to both a custom login and an OAuth provider. The next lessoncovers OAuth's errors in full.

Where clients keep tokens

A refresh token is worth stealing, so where the client stores it is a test question in itself:

  • Browser localStorage is readable by any script on the page, so one cross-site scripting bug hands over the refresh token.
  • An HttpOnlycookie can't be read by scripts, but then the browser sends it automatically, which brings back the cross-site request forgery risk covered in the last lesson.
  • Memory only is safest and is lost on every reload, so apps that use it pair it with a cookie-based way to get a fresh token.
  • Mobile apps should use the platform's secure storage, the Keychain on iOS and the Keystore on Android.

Refresh token test checklist

  1. A refresh returns a new pair, and the old refresh token is refused afterwards.
  2. A reused refresh token revokes its descendants, or the team has decided it shouldn't.
  3. Logout and password change revoke the refresh token on the server, not just in the client.
  4. An expired refresh token sends the user to log in, with a clear message, rather than an error loop.
  5. Several requests failing with 401 at once cause exactly one refresh.
  6. The client retries the original requests after refreshing, once, and doesn't loop if the retry fails.

Exercise

Log in to the JWT test API and refresh four times in a row, keeping every refresh token. Replay the second one, then try the newest. Record every response. Then log in twice, as two separate sessions, replay a token from one of them, and check whether the other session is affected. Turn what you find into an automated test that would fail if reuse detection were ever switched off.

Practise and go further