System Integration

API Key Lifecycle: 30-Day TTL, Refresh Tokens, and Sliding Expiry

API keys for TermOnMac have a 30-day TTL with sliding expiry — every successful validation extends the expiration. Refresh tokens have a 180-day TTL and rotate on use, with a 5-minute grace period for client crash recovery.

Token Generation

Both API keys and refresh tokens are random hex strings with type prefixes:

// relay_server/src/api-keys.ts
function generateApiKey(): string {
  const bytes = new Uint8Array(16);  // 128 bits
  crypto.getRandomValues(bytes);
  const hex = Array.from(bytes)
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
  return `rdkey_${hex}`;
}

function generateRefreshToken(): string {
  const bytes = new Uint8Array(64);  // 512 bits
  crypto.getRandomValues(bytes);
  // ...
  return `rdrt_${hex}`;
}

API keys are 128 bits (16 bytes); refresh tokens are 512 bits (64 bytes). The rdkey_ and rdrt_ prefixes make tokens easy to identify in logs and configuration files.

TTLs

const API_KEY_TTL_SECONDS = 30 * 86400;       // 30 days
const REFRESH_TOKEN_TTL_SECONDS = 180 * 86400; // 180 days

Sliding API Key Expiry

Every successful validateApiKey call extends the TTL by writing the record back with a fresh expirationTtl:

export async function validateApiKey(
  kv: KVNamespace,
  apiKey: string,
): Promise<ApiKeyRecord | null> {
  const json = await kv.get(`apikey:${apiKey}`);
  if (!json) return null;

  const record: ApiKeyRecord = JSON.parse(json);

  // Update last_used (sliding TTL: reset on every validation)
  record.last_used = Date.now();
  await kv.put(`apikey:${apiKey}`, JSON.stringify(record), { expirationTtl: API_KEY_TTL_SECONDS });
  // Keep user_apikey index in sync so re-login doesn't orphan active keys
  await kv.put(`user_apikey:${record.user_id}`, JSON.stringify({ api_key: apiKey }), { expirationTtl: API_KEY_TTL_SECONDS });

  // Also renew refresh token TTL while API key is actively used
  const userRefreshJson = await kv.get(`user_refresh:${record.user_id}`);
  if (userRefreshJson) {
    const { refresh_token } = JSON.parse(userRefreshJson);
    const refreshJson = await kv.get(`refresh:${refresh_token}`);
    if (refreshJson) {
      await kv.put(`refresh:${refresh_token}`, refreshJson, { expirationTtl: REFRESH_TOKEN_TTL_SECONDS });
      await kv.put(`user_refresh:${record.user_id}`, userRefreshJson, { expirationTtl: REFRESH_TOKEN_TTL_SECONDS });
    }
  }

  return record;
}

A user who connects regularly never sees their API key expire — every connection refreshes the 30-day window. A user who is inactive for 30 days needs to refresh (or re-authenticate).

The refresh token TTL is also extended on API key validation, so an active user’s refresh token stays alive without needing to be used.

Refresh Token Rotation

When a client uses a refresh token to get a new API key, the refresh token itself is rotated:

export async function validateAndRotateRefreshToken(
  kv: KVNamespace,
  refreshToken: string,
): Promise<{ api_key: string; refresh_token: string; user_id: string } | null> {
  const json = await kv.get(`refresh:${refreshToken}`);
  if (!json) return null;

  // Idempotency: if this token was already rotated (by a concurrent or retried request),
  // return the cached result instead of creating yet another token pair.
  const cachedJson = await kv.get(`rotated:${refreshToken}`);
  if (cachedJson) {
    return JSON.parse(cachedJson);
  }

  // ... issue new tokens
}

A used refresh token cannot be reused indefinitely — but two issues need handling:

  1. Idempotency: client may retry the request if the response was lost
  2. Crash recovery: client may have received the new token but crashed before persisting it

5-Minute Grace Period

Instead of deleting the old refresh token immediately, it’s kept for 5 minutes:

// Expire old refresh token with a short grace period (5 min) instead of immediate delete.
// This protects against client crash between receiving new tokens and persisting them —
// the client can retry with the old token within the grace window.
await kv.put(`refresh:${refreshToken}`, json, { expirationTtl: 300 });

If the client crashes after the server processes the rotation but before the client saves the new token, the client can retry with the old token. Within 5 minutes, the retry returns the same new token pair (via the idempotency cache).

Idempotency Cache

// Cache result so concurrent/retried requests with the same token get identical response.
// TTL matches grace period (5 min).
await kv.put(`rotated:${refreshToken}`, JSON.stringify(result), { expirationTtl: 300 });

The rotated:{token} key stores the rotation result. If the same token is presented again within 5 minutes, the cached result is returned instead of creating a new pair. This means the client’s retry returns the exact tokens it would have received originally — there’s no risk of having two valid token pairs in the wild.

Cleanup on Issuing New Token

Before issuing a new refresh token, the previous user→token index is cleared:

// Clear user→token index BEFORE creating new token, so createRefreshTokenForUser
// won't find and delete the grace-period token above.
await kv.delete(`user_refresh:${record.user_id}`);

The order matters: if the index were cleared after createRefreshTokenForUser, the old grace-period token (still indexed by the user) would be incorrectly deleted, breaking crash recovery.