Access tokens expire. Refresh tokens are the hard part.

Short-lived access tokens are universally considered best practice. Keep them under 15 minutes, the guidance says, and you limit the blast radius of a leaked credential. What the guidance rarely covers in detail is the other side of that bargain: if access tokens expire every 15 minutes, your users need a way to stay logged in without typing their password again. That's the refresh token's job — and the refresh token is where most authentication systems quietly fall apart.

This post covers the full lifecycle: how sliding window refresh works, why single-use enforcement matters, how to handle the race between concurrent requests, absolute TTL limits, and how revocation by jti gives you a proper kill switch.

The basic token pair model

The standard pattern is a two-token system. At login, your auth server issues:

  • An access token — short-lived (5–15 min), stateless JWT, carried in the Authorization header on every API request.
  • A refresh token — long-lived (days to weeks), stored securely, presented only to the auth server's /token/refresh endpoint.

The access token is read by every microservice. The refresh token is read by one service. This asymmetry is the whole point: you get stateless verification at scale while keeping the sensitive long-lived credential locked down.

Sliding window refresh

A naive refresh token has a fixed expiry: issue it at login, it dies in 30 days regardless. The problem is active users — someone using your app daily should never be prompted to log in again, while someone who hasn't opened it in 30 days should be.

Sliding window solves this. Every time a refresh token is successfully used, you issue a new one whose expiry is reset from the current time:

// On successful /token/refresh
const OLD_TTL = 30 * 24 * 60 * 60; // 30 days sliding
const ABSOLUTE_TTL = 90 * 24 * 60 * 60; // 90 days hard cap

async function rotateRefreshToken(oldToken: string, db: DB): Promise<TokenPair> {
  const record = await db.refreshTokens.findByToken(hash(oldToken));
  if (!record || record.revokedAt) throw new AuthError('TOKEN_INVALID');

  const now = Math.floor(Date.now() / 1000);
  if (now > record.expiresAt) throw new AuthError('TOKEN_EXPIRED');
  if (now > record.absoluteExpiry) throw new AuthError('SESSION_EXPIRED');

  // Single-use: revoke immediately before issuing new one
  await db.refreshTokens.revoke(record.jti, 'rotated');

  const newJti = crypto.randomUUID();
  const newToken = generateSecureToken();

  await db.refreshTokens.create({
    jti: newJti,
    userId: record.userId,
    tokenHash: hash(newToken),
    family: record.family,        // track token family for reuse detection
    parentJti: record.jti,
    issuedAt: now,
    expiresAt: now + OLD_TTL,
    absoluteExpiry: record.absoluteExpiry, // carry forward, never reset
  });

  return {
    accessToken: issueAccessJWT(record.userId, newJti),
    refreshToken: newToken,
  };
}

The key detail: absoluteExpiry is set once at session creation and never extended. This ensures that no matter how active a user is, their session eventually forces a full re-authentication. Typical values: sliding window 14–30 days, absolute cap 90–180 days.

Refresh token rotation and single-use enforcement

Refresh token rotation means each use invalidates the old token and issues a fresh one. This creates a detection mechanism for theft: if an attacker has stolen a refresh token and uses it after the legitimate client already rotated it, the server will detect a reuse attempt on an already-revoked token.

The correct response to detecting reuse is to revoke the entire token family — all tokens descended from the same original login event. This is tracked by the family field above.

async function handleRefreshRequest(rawToken: string, db: DB) {
  const record = await db.refreshTokens.findByToken(hash(rawToken));

  if (!record) {
    // Unknown token — either garbage or already garbage-collected
    throw new AuthError('TOKEN_INVALID');
  }

  if (record.revokedAt) {
    // Token was already used or explicitly revoked.
    // This is a reuse attack signal — nuke the whole family.
    const reason = record.revocationReason;
    if (reason === 'rotated') {
      // Someone is replaying an old token from this family
      await db.refreshTokens.revokeFamily(record.family, 'reuse_detected');
      throw new AuthError('REUSE_DETECTED'); // Force full re-login
    }
    throw new AuthError('TOKEN_REVOKED');
  }

  return rotateRefreshToken(rawToken, db);
}
If you detect reuse, log it. A single reuse event in isolation might be a network retry. A pattern of reuse events across many users is a credential stuffing attack or a breach of your token store. Treat it accordingly.

Handling concurrent refresh requests

The single-use model has a practical problem: mobile apps and browser tabs often fire refresh requests concurrently. If two requests arrive simultaneously with the same valid refresh token, the first succeeds and invalidates the token — the second sees a "revoked" token and incorrectly triggers the reuse path.

The standard fix is a short reuse grace window, typically 30–60 seconds. If a "rotated" token is presented again within the grace period, return the same new token rather than revoking the family:

if (record.revokedAt && record.revocationReason === 'rotated') {
  const secondsSinceRotation = now - record.revokedAt;
  if (secondsSinceRotation < 60) {
    // Within grace window — find the successor token and return it
    const successor = await db.refreshTokens.findByParentJti(record.jti);
    if (successor && !successor.revokedAt) {
      return {
        accessToken: issueAccessJWT(record.userId, successor.jti),
        refreshToken: successor.plaintext, // you need to cache this briefly
      };
    }
  }
  // Outside grace window — genuine reuse attack
  await db.refreshTokens.revokeFamily(record.family, 'reuse_detected');
  throw new AuthError('REUSE_DETECTED');
}

Note that returning the successor token requires you to have the plaintext available during the grace window — which means caching it somewhere (Redis with a 60s TTL is appropriate). Never store plaintext in your primary DB.

Revocation by jti

Every JWT carries a jti (JWT ID) claim. For refresh tokens, this is your revocation handle. When a user logs out, changes their password, or you need to kill a session remotely, you revoke by jti:

-- Mark a single session as revoked
UPDATE refresh_tokens
SET revoked_at = NOW(), revocation_reason = 'logout'
WHERE jti = $1 AND user_id = $2;

-- Revoke all sessions for a user (password change, account compromise)
UPDATE refresh_tokens
SET revoked_at = NOW(), revocation_reason = 'force_logout'
WHERE user_id = $1 AND revoked_at IS NULL;

-- Index required for performance
CREATE INDEX idx_refresh_tokens_jti ON refresh_tokens(jti);
CREATE INDEX idx_refresh_tokens_user_revoked ON refresh_tokens(user_id, revoked_at)
  WHERE revoked_at IS NULL;

For access tokens, revocation is harder because they're stateless. The common approach is to embed the session_id or a token_version in the access token and check it against a fast cache (Redis) on every request. This adds a network hop but gives you sub-minute revocation for access tokens too.

Storing refresh tokens: HttpOnly cookies

Where you store the refresh token is as important as how you rotate it. The options:

  • localStorage — accessible to any JavaScript on the page. XSS = full compromise. Never use this for refresh tokens.
  • sessionStorage — same XSS problem, and tokens die when the tab closes.
  • HttpOnly cookie — not accessible to JavaScript at all. Only sent by the browser on HTTP requests. The correct choice.
// Express: set refresh token as HttpOnly cookie
res.cookie('refresh_token', newRefreshToken, {
  httpOnly: true,          // not accessible to JS
  secure: true,            // HTTPS only
  sameSite: 'strict',      // no cross-site requests
  path: '/auth/token',     // scoped to the refresh endpoint only
  maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days in milliseconds
});

// Return access token in the response body (short-lived, OK to be in memory)
res.json({ accessToken, expiresIn: 900 });

Scoping the cookie to /auth/token with the path attribute is important. It means the browser will only send the refresh cookie when hitting that specific endpoint, not on every API call. This reduces the window for CSRF abuse even with sameSite: 'strict'.

Absolute TTL and forced re-authentication

No sliding window should be infinite. Set an absolute session maximum that cannot be extended no matter how active the user is. The right value depends on your threat model:

  • Consumer apps: 90–180 days absolute TTL is generally acceptable
  • Enterprise / high-value SaaS: 8–24 hours may be required by policy
  • Financial / healthcare: potentially session-per-operation with step-up auth

When the absolute TTL expires, the user must re-authenticate from scratch. Make this experience graceful: show a clear "Your session has expired" message, preserve their in-progress state if possible, and return them to where they were after re-login.

Putting it together: the full schema

CREATE TABLE refresh_tokens (
  id             BIGSERIAL PRIMARY KEY,
  jti            UUID NOT NULL UNIQUE,
  user_id        BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  family         UUID NOT NULL,          -- all tokens from same login share this
  parent_jti     UUID REFERENCES refresh_tokens(jti),
  token_hash     CHAR(64) NOT NULL,      -- SHA-256 of the raw token, hex-encoded
  issued_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  expires_at     TIMESTAMPTZ NOT NULL,   -- sliding window expiry
  absolute_expiry TIMESTAMPTZ NOT NULL,  -- hard cap, never extended
  revoked_at     TIMESTAMPTZ,
  revocation_reason VARCHAR(32),         -- 'logout', 'rotated', 'reuse_detected', 'force_logout'
  user_agent     TEXT,
  ip_address     INET
);

Store the ip_address and user_agent for anomaly detection. A refresh token suddenly coming from a different continent is worth flagging, even if it's technically valid.