Access and Refresh Tokens

Short-lived access, long-lived refresh, rotation on every use, and reuse detection that catches a theft.

Short access tokens are safer; making users log in every ten minutes is unacceptable. The refresh token resolves that tension: a long-lived credential whose only power is minting new access tokens.

The two tokens

Access tokenRefresh token
Lifetime5–15 minutesdays to months
Sent toevery API callonly the refresh endpoint
Formatusually a JWTusually opaque + stored
StoredmemoryHttpOnly cookie / keychain
Revocablenot reallyyes — delete the row

Rotation

Every refresh invalidates the old refresh token and issues a new one. A stolen refresh token is then useful only until the legitimate client refreshes next — typically minutes.

Reuse detection: the clever part

Because tokens rotate, a token being presented twice means something is wrong: either an attacker is using a copy, or the legitimate client is. You cannot tell which — so revoke the whole family (every descendant of the original login) and force a fresh login. One legitimate user is inconvenienced; a thief is locked out completely.

Where to keep it in a browser

An HttpOnly, Secure, SameSite=Strict cookie scoped to Path=/auth/refresh. JavaScript cannot read it, so XSS cannot steal it; it is only attached to the one endpoint that needs it; and since that endpoint does nothing but rotate, the CSRF risk is small — though a CSRF token there is still cheap insurance.

Mobile and native

iOS Keychain, Android Keystore, or an OS credential store. Not a plain file, not AsyncStorage, not a preferences plist.

Also expire the family

Rotation alone lets a session live forever. Give each family an absolute lifetime (say 30 days from the original login) so a stolen-and-quietly-rotated token still dies.

Example

Example · bash
# Login → access (10 min) + refresh (30 days, rotating)
POST /auth/login       → { accessToken, expiresIn: 600 }
                         Set-Cookie: rt=RT1; HttpOnly; Secure;
                                     SameSite=Strict; Path=/auth/refresh

# 10 minutes later
POST /auth/refresh     → { accessToken, expiresIn: 600 }
  Cookie: rt=RT1         Set-Cookie: rt=RT2   ← RT1 is now dead

# An attacker who copied RT1 tries to use it
POST /auth/refresh     → 401 { "error": "token_reuse_detected" }
  Cookie: rt=RT1         ...and RT2, RT3 and the whole family are revoked.
                         Both parties must log in again — which is the point.

When to use it

  • A stolen refresh token is detected the moment the real user refreshes, and the whole family is revoked before the attacker can do anything with it.
  • A mobile app keeps users signed in for 30 days while every API call carries a token that expires in ten minutes.
  • A support tool revokes one refresh family to sign a specific device out without disturbing the user's other devices.

More examples

Rotation with reuse detection

familyExpiresAt is copied forward rather than extended, so rotation cannot keep a session alive past its absolute 30-day cap.

Example · javascript
import { randomBytes, createHash } from 'crypto';
const hash = (t) => createHash('sha256').update(t).digest('hex');

const REFRESH_TTL_MS = 30 * 24 * 3600e3;

app.post('/auth/refresh', async (req, res) => {
  const presented = req.cookies.rt;
  if (!presented) return res.status(401).json({ error: 'no_refresh_token' });

  const row = await db.refreshTokens.findByHash(hash(presented));
  if (!row) return res.status(401).json({ error: 'invalid_refresh_token' });

  // --- reuse detection -------------------------------------------------
  // The row exists but was already rotated → someone is replaying a copy.
  if (row.usedAt) {
    await db.refreshTokens.revokeFamily(row.familyId);
    clearRefreshCookie(res);
    return res.status(401).json({ error: 'token_reuse_detected' });
  }

  if (row.revokedAt || row.familyExpiresAt < new Date()) {
    clearRefreshCookie(res);
    return res.status(401).json({ error: 'refresh_token_expired' });
  }

  // --- rotate ----------------------------------------------------------
  const next = randomBytes(32).toString('base64url');
  await db.transaction(async (tx) => {
    await tx.refreshTokens.markUsed(row.id);          // old one is now spent
    await tx.refreshTokens.insert({
      hash: hash(next),
      userId: row.userId,
      familyId: row.familyId,                          // same family
      familyExpiresAt: row.familyExpiresAt,            // absolute cap survives
      parentId: row.id,
    });
  });

  res.cookie('rt', next, {
    httpOnly: true, secure: true, sameSite: 'strict',
    path: '/auth/refresh', maxAge: REFRESH_TTL_MS,
  });
  res.json({ accessToken: issueAccessToken(row.userId), expiresIn: 600 });
});

function clearRefreshCookie(res) {
  res.clearCookie('rt', { path: '/auth/refresh', secure: true, sameSite: 'strict' });
}

The table this needs

family_id is what makes 'sign out this device' and 'sign out everywhere' two lines apart instead of two features apart.

Example · php
<?php
// database/migrations/xxxx_create_refresh_tokens_table.php
Schema::create('refresh_tokens', function (Blueprint $table) {
    $table->id();
    $table->string('hash', 64)->unique();      // sha256 hex — never the token
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->uuid('family_id')->index();        // all descendants of one login
    $table->foreignId('parent_id')->nullable();
    $table->timestamp('used_at')->nullable();  // set on rotation → reuse signal
    $table->timestamp('revoked_at')->nullable();
    $table->timestamp('family_expires_at');    // absolute cap on the session
    $table->string('user_agent')->nullable();  // for the "active devices" screen
    $table->ipAddress('ip')->nullable();
    $table->timestamps();
});

// Revoking one device
DB::table('refresh_tokens')->where('family_id', $familyId)
    ->update(['revoked_at' => now()]);

// Revoking everything (password change, account compromise)
DB::table('refresh_tokens')->where('user_id', $userId)
    ->update(['revoked_at' => now()]);

Discussion

  • Be the first to comment on this lesson.