Skip to content

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)

typescript
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)

typescript
// 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)

typescript
// 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?

  1. Timing Gap: OAuth tokens arrive before session ID exists
  2. User Identification: User sub claim available from JWT before session
  3. Security: Pending tokens auto-expire (5 min) if never claimed
  4. 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.

typescript
// 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.

typescript
// 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

typescript
// 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:

typescript
refreshToken: data.refresh_token || storedTokens.refreshToken

If 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:

typescript
// 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:

  1. Session TTL Expiration: Tokens deleted when session expires (24 hours)
  2. Explicit Deletion: Tokens deleted via DELETE /mcp endpoint
  3. Pending TTL: Unclaimed pending tokens expire after 5 minutes
  4. Refresh Failure: Access token expires and refresh token is invalid/expired

Configuration

Environment Variables

bash
# 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 requests

Code Configuration

typescript
// 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:

bash
TOKEN_AUTO_REFRESH=false

When 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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
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:

typescript
logger.warn("No refresh token available", { sessionId, userSub });
logger.warn("No pending tokens to claim", { userSub });

Error Events:

typescript
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_token grant 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:

typescript
// 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:

promql
# 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:

bash
# 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:

  1. Redis connectivity: redis-cli PING
  2. TOKEN_AUTO_REFRESH not set to false
  3. OAuth response includes refresh_token
  4. Logs for storage errors

Issue: Tokens not being claimed

Check:

  1. userSub passed to createTransport()
  2. Pending tokens exist in Redis: redis-cli GET tokens:pending:{sub}
  3. Pending tokens not expired (5-minute TTL)
  4. Logs for claiming errors

Issue: Refresh not happening

Check:

  1. TOKEN_AUTO_REFRESH=true in environment
  2. TOKEN_REFRESH_BUFFER_SECONDS configured (default: 300)
  3. Token expiration time: jwt.exp - now < buffer
  4. Refresh token available in Redis
  5. IdP token endpoint accessible

Issue: Refresh failing

Check:

  1. IdP token endpoint URL correct
  2. Refresh token not expired/revoked
  3. Client ID matches registered client
  4. Network connectivity to IdP
  5. IdP logs for error details

Debug Logging

Enable debug-level logging for token refresh operations:

bash
LOG_LEVEL=debug npm start

Debug 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

Released under the MIT License.