Refresh Token Rotation: Long Sessions Without Long-Lived Risk

A
Author
01 Aug, 2026 2 min read 879 views

Short-lived access tokens limit damage if one leaks, but users don't want to sign in every 15 minutes. Refresh tokens bridge the gap, and rotation keeps them safe.

The idea

  1. Sign-in returns a short-lived access token (for example 15 minutes) and a refresh token.
  2. When the access token expires, the client sends the refresh token to get a new pair.
  3. Each refresh token works once. Using it returns a new refresh token and invalidates the old one.
  4. If an already-used refresh token is presented again, assume theft and revoke the whole family.

Storage

CREATE TABLE refresh_tokens (
  id          UUID PRIMARY KEY,
  family_id   UUID NOT NULL,           -- all tokens from one sign-in
  user_id     BIGINT NOT NULL,
  token_hash  TEXT NOT NULL UNIQUE,    -- store a hash, never the token itself
  used_at     TIMESTAMPTZ,
  revoked_at  TIMESTAMPTZ,
  expires_at  TIMESTAMPTZ NOT NULL
);

The refresh endpoint

app.post('/auth/refresh', async (req, res) => {
  const presented = req.cookies.refresh_token;
  const record = await db.refreshTokens.findByHash(sha256(presented));

  if (!record || record.revoked_at || record.expires_at < new Date()) {
    return res.status(401).json({ error: 'Please sign in again' });
  }

  if (record.used_at) {
    // Reuse: someone has a copy. Revoke every token from this sign-in.
    await db.refreshTokens.revokeFamily(record.family_id);
    securityLog.warn({ userId: record.user_id }, 'refresh token reuse detected');
    return res.status(401).json({ error: 'Please sign in again' });
  }

  await db.refreshTokens.markUsed(record.id);
  const next = randomToken();
  await db.refreshTokens.create({ familyId: record.family_id, userId: record.user_id, hash: sha256(next) });

  res.cookie('refresh_token', next, { httpOnly: true, secure: true, sameSite: 'strict', path: '/auth/refresh' });
  res.json({ accessToken: signAccessToken(record.user_id) });
});

Details that matter

  • Mark-as-used and create-new must be atomic (one transaction) to avoid races.
  • Allow a few seconds of grace for genuine concurrent refreshes from multiple tabs, or serialise refreshes on the client.
  • Scope the cookie's path to the refresh endpoint so it isn't sent with every request.
  • Revoke all families on password change and on "sign out everywhere".
  • Set an absolute session lifetime (for example 30 days) even with rotation.
Was this article helpful?

Comments

No comments yet. Have a question or a tip? Be the first to share it.

Leave a comment

Comments are reviewed before they appear.