Token Refresh
Seed implements automatic token refresh to maintain long-lived MCP sessions without interrupting user workflows when access tokens expire.
Overview
The token refresh system provides:
- Automatic Storage: OAuth tokens saved during initial exchange
- Two-Phase Storage: Pending tokens by user ID, claimed tokens by session ID
- Proactive Refresh: Tokens refreshed before expiration (5-minute buffer)
- Reactive Refresh: Expired tokens refreshed transparently on auth failure
- Best-Effort Design: Token storage failures don't break OAuth flows
Token Refresh Flow
Complete Lifecycle
Token Storage Architecture
Storage Mechanism
Storage Service: Redis-backed token persistence (src/services/token-store.ts)
interface StoredTokens {
accessToken: string;
refreshToken: string;
expiresAt: number; // Unix timestamp when access token expires
refreshExpiresAt?: number; // Optional refresh token expiration
tokenType: string; // Usually "Bearer"
scope?: string; // Optional granted scopes
idpTokenData?: unknown; // Optional IdP-specific metadata
}
interface TokenStore {
// Two-phase storage
setPending(userSub: string, tokens: StoredTokens): Promise<void>;
claimPending(userSub: string, sessionId: string): Promise<StoredTokens | null>;
// Session-based operations
get(sessionId: string): Promise<StoredTokens | null>;
set(sessionId: string, tokens: StoredTokens): Promise<void>;
delete(sessionId: string): Promise<void>;
}Redis Storage Pattern:
# Pending tokens (5-minute TTL)
Key: tokens:pending:{user_sub}
Value: JSON.stringify(StoredTokens)
TTL: 300 seconds (5 minutes)
# Claimed tokens (24-hour TTL)
Key: tokens:session:{session_id}
Value: JSON.stringify(StoredTokens)
TTL: 86400 seconds (24 hours)Two-Phase Storage
The two-phase approach solves the timing challenge where OAuth token exchange happens before MCP session creation.
Phase 1: Pending Storage (After OAuth Exchange)
// src/routes/oauth-token.ts
const decoded = decodeJwt(data.access_token);
const userSub = decoded.sub as string;
const tokens: StoredTokens = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: decoded.exp ? decoded.exp * 1000 : Date.now() + 3600000,
tokenType: data.token_type || "Bearer",
scope: data.scope,
};
// Store as pending (5-minute TTL)
await tokenStore.setPending(userSub, tokens);Phase 2: Claimed Storage (During Session Creation)
// src/mcp/mcp.ts - onsessioninitialized callback
export function createTransport(userSub?: string): StreamableHTTPServerTransport {
return new StreamableHTTPServerTransport({
sessionIdGenerator: () => sessionId,
enableJsonResponse: true,
onsessioninitialized: async (id) => {
transports[id] = transport;
// Claim pending tokens if user sub provided
if (userSub) {
const tokenStore = getTokenStore();
const tokens = await tokenStore.claimPending(userSub, id);
if (tokens) {
logger.info("Tokens claimed for session", {
sessionId: id,
userSub,
category: "token-refresh",
});
}
}
},
});
}Why Two Phases?
- Timing Gap: OAuth tokens arrive before session ID exists
- User Identification: User
subclaim available from JWT before session - Security: Pending tokens auto-expire (5 min) if never claimed
- Reliability: Session creation doesn't fail if claim fails
Token Refresh Logic
Proactive Refresh (Before Expiration)
Proactive refresh prevents authentication interruptions by refreshing tokens before they expire.
// src/middleware/auth.ts
function shouldRefreshToken(exp: number): boolean {
const expiresAt = exp * 1000; // Convert to milliseconds
const now = Date.now();
const buffer = config.tokens.refreshBufferSeconds * 1000;
// Refresh if token expires within buffer window (default: 5 minutes)
return expiresAt - now <= buffer;
}
// After successful JWT validation
if (sessionId && shouldRefreshToken(payload.exp)) {
// Attempt refresh in background (non-blocking)
attemptTokenRefresh(sessionId, payload.sub)
.then(newToken => {
if (newToken) {
logger.info("Token refreshed proactively", {
sessionId,
userSub: payload.sub,
category: "token-refresh",
});
}
})
.catch(err => {
logger.error("Proactive refresh failed", {
sessionId,
error: err.message,
category: "token-refresh",
});
});
}
// Continue with original request (don't wait for refresh)
req.user = { sub: payload.sub, token, ... };
next();Proactive Refresh Characteristics:
- Non-Blocking: Request proceeds immediately
- 5-Minute Window: Triggers when token expires in ≤5 minutes
- Transparent: User doesn't notice token being refreshed
- Failure-Tolerant: Errors logged but request continues
Reactive Refresh (After Expiration)
Reactive refresh handles the case where a token expires before proactive refresh completes or when proactive refresh is disabled.
// src/middleware/auth.ts
try {
const { payload } = await jwtVerify(token, jwksService.getKey, {
issuer: config.oidc.issuer || undefined,
audience: config.oidc.audience || undefined,
});
// Token valid - continue
req.user = { sub: payload.sub, token, ... };
next();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// Check if error is due to expiration
if (message.includes("expired")) {
// Decode expired token to extract user sub
const expiredPayload = decodeJwt(token);
if (!expiredPayload.sub) {
return sendAuthError(res, "missing_claim", "Token missing sub claim");
}
// Attempt token refresh
const newAccessToken = await attemptTokenRefresh(sessionId, expiredPayload.sub);
if (newAccessToken) {
// Refresh successful - re-validate new token
try {
const { payload: newPayload } = await jwtVerify(newAccessToken, jwksService.getKey, {
issuer: config.oidc.issuer || undefined,
audience: config.oidc.audience || undefined,
});
// Attach user context with NEW token
req.user = {
sub: newPayload.sub as string,
token: newAccessToken, // Use refreshed token
email: newPayload.email as string | undefined,
name: newPayload.name as string | undefined,
groups: newPayload.groups as string[] | undefined,
};
logger.info("Token refreshed reactively", {
sessionId,
userSub: newPayload.sub,
category: "token-refresh",
});
return next(); // Continue with refreshed token
} catch (revalidationError) {
logger.error("Revalidation of refreshed token failed", {
sessionId,
error: revalidationError instanceof Error ? revalidationError.message : String(revalidationError),
category: "token-refresh",
});
return sendAuthError(res, "invalid_token", "Refreshed token validation failed");
}
}
// Refresh failed - return 401
return sendAuthError(res, "expired_token", "Token expired and refresh failed");
}
// Other JWT error - return 401
return sendAuthError(res, "invalid_token", message);
}Reactive Refresh Characteristics:
- Blocking: Request waits for refresh to complete
- Transparent Recovery: 401 avoided if refresh succeeds
- Token Validation: Refreshed token validated before use
- Graceful Failure: Returns 401 if refresh fails
Token Refresh Implementation
// src/middleware/auth.ts
async function attemptTokenRefresh(
sessionId: string,
userSub: string
): Promise<string | null> {
try {
// 1. Get stored tokens from Redis
const tokenStore = getTokenStore();
const storedTokens = await tokenStore.get(sessionId);
if (!storedTokens || !storedTokens.refreshToken) {
logger.warn("No refresh token available", {
sessionId,
userSub,
category: "token-refresh",
});
return null;
}
// 2. Call IdP token endpoint with refresh_token grant
const response = await fetch(config.oauth.tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: storedTokens.refreshToken,
client_id: config.oidc.audience || "",
}),
});
if (!response.ok) {
logger.error("Token refresh failed", {
sessionId,
status: response.status,
category: "token-refresh",
});
return null;
}
const data = await response.json();
// 3. Update stored tokens
const decoded = decodeJwt(data.access_token);
const updatedTokens: StoredTokens = {
accessToken: data.access_token,
refreshToken: data.refresh_token || storedTokens.refreshToken, // Handle rotation
expiresAt: decoded.exp ? decoded.exp * 1000 : Date.now() + 3600000,
tokenType: data.token_type || "Bearer",
scope: data.scope || storedTokens.scope,
};
await tokenStore.set(sessionId, updatedTokens);
logger.info("Token refreshed successfully", {
sessionId,
userSub,
newExpiry: new Date(updatedTokens.expiresAt).toISOString(),
category: "token-refresh",
});
return data.access_token;
} catch (error) {
logger.error("Token refresh error", {
sessionId,
error: error instanceof Error ? error.message : String(error),
category: "token-refresh",
});
return null;
}
}Refresh Token Rotation:
Some OIDC providers issue a new refresh token with each refresh. The code handles this:
refreshToken: data.refresh_token || storedTokens.refreshTokenIf data.refresh_token is present, use the new one. Otherwise, keep the existing refresh token.
Token Lifecycle Management
Session Creation with Token Storage
Session Deletion with Token Cleanup
When a session is terminated, associated tokens are automatically deleted:
// src/routes/mcp.ts - DELETE /mcp handler
router.delete("/", requireAuth, async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string;
if (!sessionId) {
return res.status(400).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Missing session ID" },
id: null,
});
}
// Remove MCP transport
await removeTransport(sessionId);
// Clean up stored tokens (best-effort)
try {
const tokenStore = getTokenStore();
await tokenStore.delete(sessionId);
logger.info("Tokens deleted for session", {
sessionId,
category: "token-refresh",
});
} catch (error) {
logger.error("Token cleanup failed during session deletion", {
sessionId,
error: error instanceof Error ? error.message : String(error),
category: "token-refresh",
});
// Continue - session deletion shouldn't fail due to token cleanup
}
res.status(204).send();
});Token Expiration
Tokens expire under the following conditions:
- Session TTL Expiration: Tokens deleted when session expires (24 hours)
- Explicit Deletion: Tokens deleted via
DELETE /mcpendpoint - Pending TTL: Unclaimed pending tokens expire after 5 minutes
- Refresh Failure: Access token expires and refresh token is invalid/expired
Configuration
Environment Variables
# Token Refresh Configuration
TOKEN_AUTO_REFRESH=true # Enable/disable automatic refresh (default: true)
TOKEN_REFRESH_BUFFER_SECONDS=300 # Refresh window before expiration (default: 5 minutes)
TOKEN_STORE_TTL=86400 # Session token TTL in seconds (default: 24 hours)
TOKEN_PENDING_TTL=300 # Pending token TTL in seconds (default: 5 minutes)
# Required (shared with authentication)
REDIS_URL=redis://redis:6379 # Redis connection string
OAUTH_TOKEN_URL=https://idp.example.com/token # IdP token endpoint
OIDC_AUDIENCE=my-client-id # Client ID for refresh requestsCode Configuration
// src/config/tokens.ts
export const tokensConfig = {
autoRefreshEnabled: process.env.TOKEN_AUTO_REFRESH !== "false",
refreshBufferSeconds: parseInt(process.env.TOKEN_REFRESH_BUFFER_SECONDS || "300", 10),
storeTtlSeconds: parseInt(process.env.TOKEN_STORE_TTL || "86400", 10),
pendingTtlSeconds: parseInt(process.env.TOKEN_PENDING_TTL || "300", 10),
keyPrefix: "tokens:session:",
pendingKeyPrefix: "tokens:pending:",
};Feature Toggle
Disable token refresh entirely:
TOKEN_AUTO_REFRESH=falseWhen disabled:
- Tokens NOT stored during OAuth exchange
- Tokens NOT claimed during session creation
- Proactive refresh skipped in auth middleware
- Reactive refresh skipped in auth middleware
- Reverts to original behavior (immediate 401 on expiration)
Error Handling
Best-Effort Philosophy
Token refresh is designed to enhance the user experience but should never break core functionality.
Storage Failures:
// OAuth token endpoint
try {
await tokenStore.setPending(userSub, tokens);
} catch (error) {
logger.error("Failed to store pending tokens", { error });
// Continue - OAuth flow succeeds even if storage fails
}Claiming Failures:
// Session initialization
try {
await tokenStore.claimPending(userSub, sessionId);
} catch (error) {
logger.error("Failed to claim pending tokens", { error });
// Continue - session creation succeeds even if claiming fails
}Refresh Failures:
// Proactive refresh
attemptTokenRefresh(sessionId, userSub)
.catch(err => {
logger.error("Proactive refresh failed", { error: err.message });
// Continue - request proceeds with current token
});
// Reactive refresh
const newToken = await attemptTokenRefresh(sessionId, userSub);
if (!newToken) {
// Return 401 - user must re-authenticate
return sendAuthError(res, "expired_token", "Token expired and refresh failed");
}Logging
All token refresh operations are comprehensively logged for observability:
Success Events:
logger.info("Tokens stored as pending", { userSub });
logger.info("Tokens claimed for session", { sessionId, userSub });
logger.info("Token refreshed successfully", { sessionId, userSub, newExpiry });
logger.info("Token refreshed proactively", { sessionId, userSub });
logger.info("Token refreshed reactively", { sessionId, userSub });
logger.info("Tokens deleted for session", { sessionId });Warning Events:
logger.warn("No refresh token available", { sessionId, userSub });
logger.warn("No pending tokens to claim", { userSub });Error Events:
logger.error("Failed to store pending tokens", { error });
logger.error("Failed to claim pending tokens", { error });
logger.error("Token refresh failed", { sessionId, status });
logger.error("Token refresh error", { sessionId, error });
logger.error("Proactive refresh failed", { sessionId, error });
logger.error("Revalidation of refreshed token failed", { sessionId, error });
logger.error("Token cleanup failed during session deletion", { sessionId, error });Security Considerations
Token Storage Security
Redis Security:
- Tokens stored in Redis with TTL-based expiration
- Use Redis authentication and TLS in production
- Consider Redis encryption-at-rest for sensitive data
TTL-Based Security:
- Session tokens: 24-hour TTL (configurable)
- Pending tokens: 5-minute TTL (prevents leakage)
- Automatic cleanup via Redis TTL
Refresh Token Security
Grant Type Security:
- Uses standard
refresh_tokengrant type (RFC 6749) - Refresh tokens treated as sensitive credentials
- Never exposed in logs or error messages
Rotation Handling:
- Supports refresh token rotation (IdP issues new refresh token)
- Old refresh token invalidated after use (IdP responsibility)
- Code gracefully handles both rotating and non-rotating refresh tokens
Authentication Security
Token Validation:
- Refreshed tokens validated before use
- Full JWT verification (signature, issuer, audience, expiration)
- No shortcuts or trust assumptions
Failure Modes:
- Storage failure → OAuth flow succeeds (no token refresh)
- Refresh failure → User receives 401 (must re-authenticate)
- Validation failure → User receives 401 (prevents invalid token use)
Monitoring and Observability
Metrics (Prometheus)
Token Refresh Metrics:
// Counter: Total refresh attempts
const tokenRefreshTotal = new promClient.Counter({
name: 'token_refresh_total',
help: 'Total token refresh attempts',
labelNames: ['result', 'type'], // result: success|failure, type: proactive|reactive
});
// Histogram: Refresh duration
const tokenRefreshDuration = new promClient.Histogram({
name: 'token_refresh_duration_seconds',
help: 'Token refresh operation duration',
labelNames: ['result'],
buckets: [0.1, 0.5, 1, 2, 5, 10],
});PromQL Queries:
# Refresh success rate
rate(token_refresh_total{result="success"}[5m])
/ rate(token_refresh_total[5m])
# Proactive vs reactive refreshes
rate(token_refresh_total{type="proactive"}[5m])
rate(token_refresh_total{type="reactive"}[5m])
# Refresh latency (95th percentile)
histogram_quantile(0.95, rate(token_refresh_duration_seconds_bucket[5m]))Structured Logging
All token refresh operations use structured logging with the category: "token-refresh" field for easy filtering:
# View all token refresh logs
cat logs/app.log | jq 'select(.category == "token-refresh")'
# View refresh failures
cat logs/app.log | jq 'select(.category == "token-refresh" and .level == "error")'
# View proactive refreshes
cat logs/app.log | jq 'select(.message | contains("proactively"))'Troubleshooting
Common Issues
Issue: Tokens not being stored
Check:
- Redis connectivity:
redis-cli PING TOKEN_AUTO_REFRESHnot set tofalse- OAuth response includes
refresh_token - Logs for storage errors
Issue: Tokens not being claimed
Check:
userSubpassed tocreateTransport()- Pending tokens exist in Redis:
redis-cli GET tokens:pending:{sub} - Pending tokens not expired (5-minute TTL)
- Logs for claiming errors
Issue: Refresh not happening
Check:
TOKEN_AUTO_REFRESH=truein environmentTOKEN_REFRESH_BUFFER_SECONDSconfigured (default: 300)- Token expiration time:
jwt.exp - now < buffer - Refresh token available in Redis
- IdP token endpoint accessible
Issue: Refresh failing
Check:
- IdP token endpoint URL correct
- Refresh token not expired/revoked
- Client ID matches registered client
- Network connectivity to IdP
- IdP logs for error details
Debug Logging
Enable debug-level logging for token refresh operations:
LOG_LEVEL=debug npm startDebug logs include:
- Token storage operations
- Token claiming operations
- Refresh trigger conditions
- IdP request/response details (tokens redacted)
Performance Considerations
Redis Load
Writes:
- 1 write per OAuth token exchange (pending storage)
- 1 write per session creation (claim + re-key)
- 1 write per token refresh (update)
- 1 write per session deletion (cleanup)
Reads:
- 1 read per session creation (claim)
- 1 read per token refresh attempt (get refresh token)
Optimization:
- Consider Redis pipelining for bulk operations
- Use Redis clustering for high-volume deployments
- Monitor Redis memory usage and eviction policy
Refresh Latency
Proactive Refresh:
- Non-blocking (happens in background)
- No impact on request latency
- Typical duration: 100-500ms (IdP dependent)
Reactive Refresh:
- Blocking (request waits for refresh)
- Adds latency to first expired request
- Typical duration: 100-500ms (IdP dependent)
Recommendation: Configure TOKEN_REFRESH_BUFFER_SECONDS appropriately to maximize proactive refreshes and minimize reactive refreshes.
Implementation Files
- Token Store:
src/services/token-store.ts- Redis-backed storage service - Auth Middleware:
src/middleware/auth.ts- Proactive and reactive refresh logic - OAuth Token Route:
src/routes/oauth-token.ts- Pending token storage - MCP Route:
src/routes/mcp.ts- Token claiming and cleanup - MCP Transport:
src/mcp/mcp.ts- Transport creation with token claiming - Token Config:
src/config/tokens.ts- Configuration and environment variables - Tests:
src/services/token-store.test.ts,src/middleware/auth.test.ts
Related Documentation
- Authentication Flow - JWT validation and user context
- Session Management - MCP session lifecycle
- OAuth 2.1 Implementation - Token exchange and authorization
- Configuration System - Environment-based configuration