Fullstack CourseLearn by building
Back to week 3

Topic

Refresh tokens & session revocation

Definition

A refresh token is a long-lived credential a client exchanges for short-lived access tokens; rotation issues a new refresh token on each use and invalidates the previous one; revocation is the server’s ability to end a session before its token would naturally expire.

In simpler words

Instead of one long-lived login token, you keep a short access token that expires fast and a refresh token that quietly gets you a new one — and the server can cut either off early if something looks wrong.

A common baseline issues a single access-token cookie that lives for a fixed expiry (say 7d) with no refresh and no server-side revocation. Use that as the baseline: understand what a refresh + rotation flow adds and why production auth usually wants it.

After this you can

  • Explain why a short access token plus a refresh token beats one long-lived token
  • Describe refresh-token rotation and reuse detection
  • Name the server-side state a revocable session needs

A single-token baseline versus the refresh model

Definition

A refresh flow splits authentication into a short-lived access token used on every request and a long-lived refresh token stored separately and sent only to a dedicated refresh endpoint, so a leaked access token expires quickly while sessions still last for days.

In simpler words

In this baseline one 7-day cookie does both jobs; splitting them means a token stolen from a request only works for minutes.

AuthService.login here signs one JWT with a multi-day maxAge and never reissues it — if that token leaks, it stays valid until it expires because nothing can revoke it.

A refresh model shortens the access token to minutes and adds a POST /auth/refresh route that reads the refresh token and mints a fresh access token, so the high-value credential is used rarely and on one path only.

Baseline: one long-lived token

// One token, ~7 days, no refresh, no revoke
const token = await this.jwtService.signAsync({ sub, email, role });
res.cookie(AUTH_COOKIE_NAME, token, { httpOnly: true, maxAge: 7 * 24 * 60 * 60 * 1000 });

Simple and fine for a course, but a stolen token stays valid for a week with no way to cut it off.

Refresh model sketch

// Access token: minutes
const access = await jwt.signAsync({ sub, role }, { expiresIn: '15m' });
// Refresh token: days, stored server-side + in its own httpOnly cookie
const refresh = await this.issueRefreshToken(user.id);
res.cookie('access_token', access, { httpOnly: true, maxAge: 15 * 60 * 1000 });
res.cookie('refresh_token', refresh, { httpOnly: true, path: '/auth/refresh' });

The refresh cookie is scoped to the refresh path so it is not attached to every ordinary request.

Rotation and reuse detection

Definition

Refresh-token rotation replaces the refresh token every time it is used and marks the old one as spent, so a refresh token is valid exactly once; if a spent token is presented again, the server treats it as theft and revokes the whole session family.

In simpler words

Each refresh hands you a new refresh token and burns the old one — if an old one shows up again, someone copied it, so you log the session out entirely.

Store a hashed refresh token (or a token id + version) per session; on refresh, verify it matches the current one, then issue and persist a new one.

Reuse detection is the payoff: if both an attacker and the user hold a copy, one of them presents an already-rotated token, and that mismatch is the signal to revoke the family.

Rotation with reuse detection (sketch)

async refresh(presented: string) {
  const session = await this.sessions.findByToken(hash(presented));
  if (!session || session.revokedAt) throw new UnauthorizedException();
  if (session.currentTokenHash !== hash(presented)) {
    await this.sessions.revokeFamily(session.familyId); // reuse detected
    throw new UnauthorizedException('Token reuse detected');
  }
  const next = await this.issueRefreshToken(session.userId, session.familyId);
  await this.sessions.rotate(session.id, hash(next));
  return next;
}

Storing only the hash means a leaked database still does not hand out usable refresh tokens.

Revocation and logout-everywhere

Definition

Because a pure JWT is valid until it expires, ending a session early requires server-side state — a per-session record or a per-user token version — that guarded requests consult so a logout, password change, or admin action can invalidate tokens immediately.

In simpler words

Revocation is the thing plain JWTs cannot do alone: you need a small server-side list (or version number) to say “this session is over now.”

Logout today only clears the cookie — the token string itself would still verify if copied. Revocation needs a stored session you can mark revoked, or a tokenVersion on the user that every access token carries and the guard compares.

Logout-everywhere is bumping the user’s tokenVersion (or revoking all sessions in the family) so every previously issued token stops validating on its next use.

Token version check (sketch)

// Access token carries the version it was signed with
const access = await jwt.signAsync({ sub: user.id, ver: user.tokenVersion });

// Guard rejects tokens signed before a logout-everywhere bump
if (payload.ver !== user.tokenVersion) throw new UnauthorizedException();

Incrementing user.tokenVersion invalidates every outstanding access token on its next request — cheap logout-everywhere without a session table.

Keep in mind

  • Keep access tokens short-lived; let a refresh token carry session length.
  • Rotate refresh tokens on every use and treat reuse of a spent token as theft.
  • Store only a hash of the refresh token, never the raw value.
  • Real revocation needs server-side state — a session record or a per-user token version.

Test

Check your understanding

At least 10 questions — mix of concept, syntax, practical, and logic. Score ≥80% (enforced by the API) to save progress.

Checking your session…

14 questions · concept 4 · syntax 3 · practical 4 · logic 3

1. Why split auth into a short access token and a long-lived refresh token?
Concept
2. What does refresh-token rotation do on each use?
Syntax
3. What does reuse of an already-rotated refresh token signal?
Practical
4. Why can a plain JWT not be revoked before it expires?
Logic
5. What does this API do today for session length?
Concept
6. How should a refresh token be stored server-side?
Practical
7. What is a practical way to implement logout-everywhere?
Syntax
8. Why scope the refresh cookie to the refresh endpoint path?
Logic
9. What is the security cost of simply making the access token last 30 days?
Concept
10. What extra state does a revocable session require beyond a JWT?
Practical
11. What is wrong with this logout?
Conceptintermediate
async logout(res) {
  res.clearCookie('access_token');
}
12. Which check detects refresh-token reuse here?
Syntaxintermediate
if (session.currentTokenHash !== hash(presented)) {
  // ???
}
13. What does ver enable in this access token?
Practicalintermediate
const access = await jwt.signAsync({ sub: user.id, ver: user.tokenVersion });
14. Which cookie setup matches the refresh model?
Logicintermediate
res.cookie('access_token', access, { httpOnly: true, maxAge: 15 * 60 * 1000 });
res.cookie('refresh_token', refresh, { httpOnly: true, path: '/auth/refresh' });

Checking your session…