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:
- Idempotency: client may retry the request if the response was lost
- 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.