Skip to main content
PProfileMatch

JWT Login Flow: Access Tokens, Refresh Tokens and Rotation

Backend DevelopmentLesson 2 of 47 min read

What you'll learn

  • Explain why a login issues two tokens with different lifetimes
  • Hash and verify passwords with scrypt and avoid revealing which half of a login was wrong
  • Implement refresh-token rotation and detect a replayed refresh token
  • Return correct 401 responses with a WWW-Authenticate header

On this page

  1. Two tokens, two jobs
  2. The service
  3. Common mistakes and the symptoms they cause
  4. How this shows up in real projects and interviews

A co-working space builds a door-unlock app. The first version issues a JWT that lasts 30 days, so nobody has to log in again. Then a member loses their phone, and the space discovers there is no way to stop that token working until it expires. The second version makes the token last 5 minutes, and now members are thrown back to the login screen mid-coffee. The way out of that trade-off is two tokens: a short-lived one for calling the API and a longer-lived one whose only job is to get a new short-lived one, and which the server can cancel.

Two tokens, two jobs

Access tokenRefresh token
PurposeAuthorise API callsObtain a new access token
LifetimeMinutes (5 to 15 is common)Days or weeks
FormatJWT, verified without a database lookupOpaque random string, looked up on the server
Sent toEvery API request, as Authorization: Bearer <token>Only the refresh endpoint
If stolenUsable until it expiresDangerous, so it must be revocable and rotated
Stored server-sideNoYes, as a hash
The flow
App                    Auth service
 |                          |
 | POST /login              |
 | (email + password) ----> |
 | <---- access + refresh   |
 |                          |
 | GET /me                  |
 | Bearer access ---------> |
 | <---- 200                |
 |                          |
 | ... access expires ...   |
 | GET /me ---------------> |
 | <---- 401 token expired  |
 |                          |
 | POST /refresh            |
 | (refresh token) -------> |
 | <---- NEW access + NEW   |
 |       refresh; old one   |
 |       is now spent       |

The service

This single file implements login, refresh with rotation and a protected route, using the jwt-lib.mjs from the previous lesson and Node's built-in HTTP module. The demo user's password is hashed with scrypt, a deliberately slow, salted password hash, so a database leak does not reveal passwords. Refresh tokens are random bytes rather than JWTs, and the server stores only their SHA-256 hash, so a read-only database leak does not hand out usable tokens either.

auth-server.mjs
import { createServer } from "node:http";
import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
import { sign, verify } from "./jwt-lib.mjs";

const SECRET = process.env.JWT_SECRET;
if (!SECRET || SECRET.length < 32) throw new Error("set JWT_SECRET to at least 32 characters");
const ACCESS_TTL = Number(process.env.ACCESS_TTL_SECONDS ?? 300);
const REFRESH_TTL = 7 * 24 * 3600;

// One demo user. Real systems store only a salted, slow hash like this one.
const salt = randomBytes(16);
const users = new Map([
  ["asha@example.test", {
    id: "usr_77", role: "member", salt,
    hash: scryptSync("correct horse battery", salt, 32),
  }],
]);

const refreshStore = new Map(); // sha256(token) -> { userId, family, used, expires }
const sha = (text) => createHash("sha256").update(text).digest("hex");
const now = () => Math.floor(Date.now() / 1000);

function issueTokens(user, family = randomBytes(8).toString("hex")) {
  const accessToken = sign(
    { sub: user.id, role: user.role, iat: now(), exp: now() + ACCESS_TTL }, SECRET);
  const refreshToken = randomBytes(32).toString("base64url"); // opaque, not a JWT
  refreshStore.set(sha(refreshToken), {
    userId: user.id, family, used: false, expires: now() + REFRESH_TTL,
  });
  return { access_token: accessToken, refresh_token: refreshToken,
           token_type: "Bearer", expires_in: ACCESS_TTL };
}

const send = (res, status, body) => {
  res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
  res.end(JSON.stringify(body));
};
const readBody = async (req) => {
  const chunks = [];
  for await (const c of req) chunks.push(c);
  try { return JSON.parse(Buffer.concat(chunks).toString() || "{}"); } catch { return {}; }
};

createServer(async (req, res) => {
  const { pathname } = new URL(req.url, "http://localhost");

  if (req.method === "POST" && pathname === "/login") {
    const { email, password } = await readBody(req);
    const user = users.get(email);
    const attempt = scryptSync(String(password ?? ""), user?.salt ?? salt, 32);
    if (!user || !timingSafeEqual(attempt, user.hash)) {
      return send(res, 401, { error: "invalid credentials" }); // same message either way
    }
    return send(res, 200, issueTokens(user));
  }

  if (req.method === "POST" && pathname === "/refresh") {
    const { refresh_token } = await readBody(req);
    const record = refreshStore.get(sha(String(refresh_token ?? "")));
    if (!record || record.expires <= now()) return send(res, 401, { error: "invalid refresh token" });
    if (record.used) {
      for (const [key, r] of refreshStore) if (r.family === record.family) refreshStore.delete(key);
      return send(res, 401, { error: "refresh token reuse detected; session revoked" });
    }
    record.used = true;
    const user = [...users.values()].find((u) => u.id === record.userId);
    return send(res, 200, issueTokens(user, record.family));
  }

  if (req.method === "GET" && pathname === "/me") {
    const match = /^Bearer (.+)$/.exec(req.headers.authorization ?? "");
    if (!match) return send(res, 401, { error: "missing bearer token" });
    try {
      const claims = verify(match[1], SECRET);
      return send(res, 200, { user: claims.sub, role: claims.role });
    } catch (err) {
      res.setHeader("www-authenticate", 'Bearer error="invalid_token"');
      return send(res, 401, { error: err.message });
    }
  }

  send(res, 404, { error: "not found" });
}).listen(Number(process.env.PORT ?? 4100), () => console.log("auth demo listening"));

A few design choices are worth pointing out. The login route returns the same invalid credentials message for an unknown email and for a wrong password, and still performs a hash computation in both cases, so a caller cannot use either the message or the response time to discover which emails have accounts. Responses carry cache-control: no-store so proxies and browsers never keep tokens. And the secret refuses to load unless it is at least 32 characters.

Rotation is the interesting part. Each refresh token is single-use: when it is exchanged, the server marks it used and issues a *new* refresh token in the same family. If a token that was already used shows up again, then two parties hold copies, one of whom is not the legitimate client. The server cannot tell which one, so it revokes the entire family and forces a fresh login. The client below exercises each of these paths. The server was started with JWT_SECRET set to a demo value and ACCESS_TTL_SECONDS=2 so that expiry can be observed in a few seconds.

client.mjs
const BASE = "http://localhost:4100";
const wait = (ms) => new Promise((r) => setTimeout(r, ms));

async function call(method, path, { body, token } = {}) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      ...(body ? { "content-type": "application/json" } : {}),
      ...(token ? { authorization: `Bearer ${token}` } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  return { status: res.status, json: await res.json() };
}

console.log("no token:        ", (await call("GET", "/me")).status);
const bad = await call("POST", "/login", { body: { email: "asha@example.test", password: "guess" } });
console.log("wrong password:  ", bad.status, bad.json);

const login = await call("POST", "/login", {
  body: { email: "asha@example.test", password: "correct horse battery" },
});
const first = login.json;
console.log("login:           ", login.status, Object.keys(first), "expires_in", first.expires_in);
console.log("access token:    ", first.access_token.split(".").length, "dot-separated parts");
console.log("refresh token:   ", first.refresh_token.length, "random characters, no dots");
console.log("GET /me:         ", (await call("GET", "/me", { token: first.access_token })).json);

const second = (await call("POST", "/refresh", { body: { refresh_token: first.refresh_token } })).json;
console.log("rotated:         ", second.refresh_token !== first.refresh_token);

const replay = await call("POST", "/refresh", { body: { refresh_token: first.refresh_token } });
console.log("replay old one:  ", replay.status, replay.json);
const after = await call("POST", "/refresh", { body: { refresh_token: second.refresh_token } });
console.log("newer one now:   ", after.status, after.json);

console.log("waiting for the short-lived access token to expire...");
const fresh = (await call("POST", "/login", {
  body: { email: "asha@example.test", password: "correct horse battery" },
})).json;
await wait(3000);
console.log("expired access:  ", (await call("GET", "/me", { token: fresh.access_token })).json);

Output

no token:         401
wrong password:   401 { error: 'invalid credentials' }
login:            200 [ 'access_token', 'refresh_token', 'token_type', 'expires_in' ] expires_in 2
access token:     3 dot-separated parts
refresh token:    43 random characters, no dots
GET /me:          { user: 'usr_77', role: 'member' }
rotated:          true
replay old one:   401 { error: 'refresh token reuse detected; session revoked' }
newer one now:    401 { error: 'invalid refresh token' }
waiting for the short-lived access token to expire...
expired access:   { error: 'token expired' }

Follow the sequence in the output. The wrong password and the missing token both yield 401. A successful login returns four fields, and the access token has three dot-separated parts while the refresh token is a 43-character random string with no dots, because it is not a JWT at all. After the first refresh the old token is spent: replaying it is rejected with a message and the whole family is revoked, which is why even the *newer* refresh token fails afterwards. Finally, the 2-second access token stops working after the short wait, with the verifier's token expired message. In a real client, that 401 is the signal to call /refresh once and retry the original request.

A raw 401 from the protected route also carries a WWW-Authenticate header, which HTTP requires on 401 responses and which tells clients which scheme is expected:

curl401.sh
curl -s -i http://localhost:4100/me -H "Authorization: Bearer not-a-real-token"

Output

HTTP/1.1 401 Unauthorized
www-authenticate: Bearer error="invalid_token"
content-type: application/json
cache-control: no-store
Transfer-Encoding: chunked

{"error":"malformed token"}

Common mistakes and the symptoms they cause

  • A long-lived access token instead of the pair. Symptom: you cannot log out a stolen device, and "sign out everywhere" does nothing until the token expires.
  • Making the refresh token a JWT with no server-side record. Symptom: you cannot revoke it or detect reuse, so it is just a long-lived access token wearing a different name.
  • Storing refresh tokens in plain text. Symptom: a database leak or a debug dump becomes a mass account takeover. Store a hash.
  • Different login errors for unknown email and wrong password. Symptom: an attacker can list which emails have accounts. Use one message.
  • Not limiting login attempts. Symptom: credential-stuffing traffic in the logs, and CPU spikes because each attempt runs a slow password hash.
  • Several tabs refreshing at once. Symptom: random logouts, because tab B presents a refresh token that tab A just spent and the reuse detector fires. Serialise refreshes in the client, or allow a short grace period for the most recently spent token.
  • Sending tokens in the URL. Symptom: access tokens in server logs, browser history and Referer headers. Use the Authorization header or a cookie.

How this shows up in real projects and interviews

Almost every product with a mobile app or a single-page app ends up with this two-token design, whether it is hand-built or provided by an identity service. Interviewers ask "how would you log a user out when you use JWTs?" and "what is refresh-token rotation?" The answer they want is that stateless access tokens are cheap to verify but cannot be recalled, so you keep them short and put revocation power in a server-side refresh token. If you can describe reuse detection and the multi-tab race, you are ahead of most candidates. Next: where the browser should keep these tokens, in Where to Store a JWT: HttpOnly Cookies vs localStorage.

Try it yourself

Add POST /logout to auth-server.mjs. It should take a refresh token, delete the whole family it belongs to, and answer 204. Then extend client.mjs: log in, call logout with the refresh token, and confirm that a refresh attempt afterwards returns 401. Finally, add an absolute session limit: store the family's creation time and refuse to rotate tokens in a family older than 30 days even if each token is fresh.

Show a hint

Store familyStartedAt in each refresh record when you create a new family and copy it across when rotating. Compare it with now() in the refresh route before marking the token used.

Key takeaways

  • Use a short-lived access token for API calls and a longer-lived, revocable refresh token only for getting new access tokens.
  • Make refresh tokens opaque random values, store only their hash, and rotate them on every use.
  • If a spent refresh token is presented again, revoke the whole token family and require a new login.
  • Hash passwords with a slow salted function such as scrypt, and return one generic message for any bad credential.
  • Respond to bad tokens with 401 and a WWW-Authenticate header, and have clients refresh once on a 401 before retrying.

Test yourself

A trick from this lesson's ride: Access token = day pass, quick to expire. Refresh token = member card the desk can cancel. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

What is the difference between an access token and a refresh token?

An access token is short-lived (minutes) and sent with every API request to prove who the caller is. A refresh token lives much longer, is sent only to the token endpoint, and is exchanged for a new access token when the old one expires. Because refresh tokens are powerful, servers store them (hashed), rotate them and can revoke them.

How long should a JWT access token last?

Short, commonly between 5 and 15 minutes, because a stolen access token cannot be recalled and stays valid until it expires. The right value depends on your risk: sensitive actions justify shorter lifetimes, while very short lifetimes increase refresh traffic. Keep the lifetime short and rely on refresh tokens for convenience.

What is refresh token rotation?

Rotation means every use of a refresh token returns a new refresh token and marks the old one as spent. If a spent token is presented again, the server knows a copy has leaked, revokes the entire family of tokens and forces the user to log in again. This limits how long a stolen refresh token is useful.