# How Session Persistence Works in HTTP Mode for n8n-MCP Multi-Tenant Deployments

> Explore session persistence in n8n-MCP HTTP mode. Learn how it saves tenant connections and API keys for seamless multi-tenant context restoration after restarts.

- Repository: [Romuald Członkowski/n8n-mcp](https://github.com/czlonkowski/n8n-mcp)
- Tags: internals
- Published: 2026-03-24

---

**Session persistence in n8n-MCP's HTTP mode captures tenant-specific session metadata—including API endpoints and keys—into serializable state objects via `exportSessionState()`, allowing containers to restore multi-tenant contexts after restarts without losing active n8n instance connections.**

In multi-tenant deployments where `ENABLE_MULTI_TENANT=true`, the n8n-MCP HTTP server creates isolated transport and MCP server pairs for each client session identified by headers like `x-n8n-url`, `x-n8n-key`, and `x-instance-id`. Because cloud-native containers restart frequently, the system implements a **session-persistence API** in [`src/http-server-single-session.ts`](https://github.com/czlonkowski/n8n-mcp/blob/main/src/http-server-single-session.ts) that serializes only safe-to-export context data, while requiring operators to encrypt sensitive fields before disk storage.

## What Gets Persisted in Session State?

The persistence layer stores minimal, serializable data defined in [`src/types/session-state.ts`](https://github.com/czlonkowski/n8n-mcp/blob/main/src/types/session-state.ts). Only metadata and context required to reconstruct a tenant connection survive process restarts; transient transport objects are recreated lazily upon first request.

### Core Session Fields

Each exported session contains the following fields:

| Field | Purpose |
|-------|---------|
| `sessionId` | Unique identifier matching the transport/SDK header value |
| `metadata.createdAt` / `metadata.lastAccess` | ISO-8601 timestamps for expiration checks during restore |
| `context.n8nApiUrl` | Tenant's n8n instance endpoint from the `x-n8n-url` header |
| `context.n8nApiKey` | **Plain-text API key** (requires external encryption before persistence) |
| `context.instanceId` | Optional tenant-provided identifier; defaults to `sessionId` if omitted |
| `context.sessionId` | Optional override when a proxy supplies its own value |
| `context.metadata` | Arbitrary client data such as user-agent or IP address |

**Security Warning:** The exported payload contains plaintext n8n API keys. The caller **must encrypt** this data before writing to disk or remote storage.

## Exporting Sessions Before Shutdown

When a container receives a shutdown signal (SIGTERM) or when the host requests a snapshot, the server invokes `exportSessionState()` in [`src/http-server-single-session.ts`](https://github.com/czlonkowski/n8n-mcp/blob/main/src/http-server-single-session.ts) (lines 1574–1580). This method iterates over `this.sessionMetadata`, skipping duplicates, expired entries, and sessions lacking valid context, then returns an array of `SessionState` objects.

```typescript
// src/http-server-single-session.ts – export implementation (simplified)
public exportSessionState(): SessionState[] {
  const sessions: SessionState[] = [];
  const seenSessionIds = new Set<string>();

  for (const sessionId of Object.keys(this.sessionMetadata)) {
    if (seenSessionIds.has(sessionId)) continue;
    if (this.isSessionExpired(sessionId)) continue;

    const metadata = this.sessionMetadata[sessionId];
    const context = this.sessionContexts[sessionId];
    if (!context?.n8nApiUrl || !context?.n8nApiKey) continue;

    seenSessionIds.add(sessionId);
    sessions.push({
      sessionId,
      metadata: {
        createdAt: metadata.createdAt.toISOString(),
        lastAccess: metadata.lastAccess.toISOString(),
      },
      context: {
        n8nApiUrl: context.n8nApiUrl,
        n8nApiKey: context.n8nApiKey,
        instanceId: context.instanceId || sessionId,
        sessionId: context.sessionId,
        metadata: context.metadata,
      },
    });
  }

  logger.info(`Exported ${sessions.length} session(s) for persistence`);
  logSecurityEvent('session_export', { count: sessions.length });
  return sessions;
}

```

The implementation logs a `session_export` security event and emits a clear warning: exported data contains plaintext API keys that must be encrypted by the downstream persistence layer.

### Practical Export with Encryption

```typescript
import { SingleSessionHTTPServer } from './http-server-single-session';
import { writeFile } from 'fs/promises';
import { createCipheriv, randomBytes } from 'crypto';

const server = new SingleSessionHTTPServer();

async function persistSessions() {
  const sessions = server.exportSessionState();
  
  // Encrypt before writing (example using AES-256-GCM)
  const key = Buffer.from(process.env.SESSION_ENCRYPTION_KEY!, 'hex');
  const iv = randomBytes(12);
  const cipher = createCipheriv('aes-256-gcm', key, iv);
  const encrypted = Buffer.concat([
    cipher.update(JSON.stringify(sessions), 'utf8'),
    cipher.final(),
  ]);
  const tag = cipher.getAuthTag();
  
  const payload = Buffer.concat([iv, tag, encrypted]);
  await writeFile('/data/sessions.bin', payload);
}

process.on('SIGTERM', async () => {
  await persistSessions();
  process.exit(0);
});

```

## Restoring Sessions on Startup

On startup, the host feeds previously saved state into `restoreSessionState()` (lines 1622–1648 in [`src/http-server-single-session.ts`](https://github.com/czlonkowski/n8n-mcp/blob/main/src/http-server-single-session.ts)). This method validates each entry, enforces the `MAX_SESSIONS` limit, and repopulates internal maps while leaving transport objects empty—these are instantiated lazily when the first HTTP request arrives for that session.

```typescript
// src/http-server-single-session.ts – restore implementation (simplified)
public restoreSessionState(sessions: SessionState[]): number {
  let restoredCount = 0;

  for (const sessionState of sessions) {
    if (!sessionState?.sessionId) continue;
    if (Object.keys(this.sessionMetadata).length >= MAX_SESSIONS) break;
    if (this.sessionMetadata[sessionState.sessionId]) continue;

    const createdAt = new Date(sessionState.metadata.createdAt);
    const lastAccess = new Date(sessionState.metadata.lastAccess);
    if (isNaN(createdAt.getTime()) || isNaN(lastAccess.getTime())) continue;
    if (Date.now() - lastAccess.getTime() > this.sessionTimeout) continue;

    const validation = validateInstanceContext(sessionState.context);
    if (!validation.valid) {
      logSecurityEvent('session_restore_failed', { 
        sessionId: sessionState.sessionId, 
        reason: validation.errors?.join(', ') 
      });
      continue;
    }

    this.sessionMetadata[sessionState.sessionId] = { createdAt, lastAccess };
    this.sessionContexts[sessionState.sessionId] = {
      n8nApiUrl: sessionState.context.n8nApiUrl,
      n8nApiKey: sessionState.context.n8nApiKey,
      instanceId: sessionState.context.instanceId,
      sessionId: sessionState.context.sessionId,
      metadata: sessionState.context.metadata,
    };

    logSecurityEvent('session_restore', { 
      sessionId: sessionState.sessionId, 
      instanceId: sessionState.context.instanceId 
    });
    restoredCount++;
  }

  logger.info(`Restored ${restoredCount}/${sessions.length} session(s) from persistence`);
  return restoredCount;
}

```

Sessions that pass validation enter a **dormant** state—metadata and context exist, but no `StreamableHTTPServerTransport` or MCP server instance runs until a client sends a request with the matching `mcp-session-id` header.

### Practical Restore with Decryption

```typescript
import { SingleSessionHTTPServer } from './http-server-single-session';
import { readFile } from 'fs/promises';
import { createDecipheriv } from 'crypto';

async function loadSessions(): Promise<any[]> {
  const data = await readFile('/data/sessions.bin');
  const iv = data.slice(0, 12);
  const tag = data.slice(12, 28);
  const ciphertext = data.slice(28);
  
  const key = Buffer.from(process.env.SESSION_ENCRYPTION_KEY!, 'hex');
  const decipher = createDecipheriv('aes-256-gcm', key, iv);
  decipher.setAuthTag(tag);
  
  const decrypted = Buffer.concat([
    decipher.update(ciphertext),
    decipher.final()
  ]);
  return JSON.parse(decrypted.toString('utf8'));
}

(async () => {
  const server = new SingleSessionHTTPServer();
  const saved = await loadSessions();
  const restored = server.restoreSessionState(saved);
  console.log(`Restored ${restored} sessions`);
})();

```

## Session Lifecycle and HTTP Request Flow

Understanding the interaction between HTTP requests and persisted sessions clarifies how n8n-MCP maintains continuity across container restarts.

### Session Initialization

When a client sends an **Initialize request** (`isInitializeRequest`), the server creates a new transport and MCP server pair, stores them in `this.transports` and `this.servers`, and records the tenant context extracted from `x-n8n-url`, `x-n8n-key`, and optional `x-instance-id` headers.

### Dormant Sessions and Lazy Transport Creation

If `restoreSessionState()` repopulated a session from disk but no client has connected yet, the session remains **dormant**—it has metadata and context but no active transport. The first subsequent HTTP request bearing the correct `mcp-session-id` header triggers instantiation of a fresh `StreamableHTTPServerTransport` exactly as during normal initialization, binding the new transport to the restored tenant context.

### Automatic Cleanup

A background task runs every 5 minutes (`SESSION_CLEANUP_INTERVAL`) to remove entries whose `lastAccess` exceeds `sessionTimeout` (default 5 minutes, configurable via `SESSION_TIMEOUT_MINUTES`). This prevents restored sessions that are never reused from accumulating memory indefinitely.

## Security Considerations for Multi-Tenant Data

The session persistence mechanism delegates encryption responsibility to the operator. The raw export contains sensitive tenant credentials in plaintext, making external encryption mandatory before network or disk storage. All restoration failures, exports, and max-session-limit breaches generate security audit logs via `logSecurityEvent()` with event types including `session_export`, `session_restore`, `session_restore_failed`, and `max_sessions_reached`.

## Programmatic Access via N8NMCPEngine

For higher-level integration, the `N8NMCPEngine` wrapper in [`src/mcp-engine.ts`](https://github.com/czlonkowski/n8n-mcp/blob/main/src/mcp-engine.ts) exposes identical persistence methods that delegate to the underlying HTTP server.

```typescript
import { N8NMCPEngine } from './mcp-engine';
import { readFile, writeFile } from 'fs/promises';

const engine = new N8NMCPEngine();

// Export current sessions
const sessions = engine.exportSessionState();
await writeFile('sessions.json', JSON.stringify(sessions, null, 2));

// Restore on next startup
const loaded = JSON.parse(await readFile('sessions.json', 'utf8'));
engine.restoreSessionState(loaded);

```

**Key reference:** `N8NMCPEngine.exportSessionState()` and `restoreSessionState()` in [`src/mcp-engine.ts`](https://github.com/czlonkowski/n8n-mcp/blob/main/src/mcp-engine.ts) (line 139).

## Summary

- **Session state** in n8n-MCP HTTP mode stores only serializable metadata and tenant context in [`src/types/session-state.ts`](https://github.com/czlonkowski/n8n-mcp/blob/main/src/types/session-state.ts), excluding active transport objects.
- **`exportSessionState()`** (lines 1574–1580 in [`src/http-server-single-session.ts`](https://github.com/czlonkowski/n8n-mcp/blob/main/src/http-server-single-session.ts)) serializes valid, non-expired sessions while logging security events; operators must encrypt the resulting payload externally.
- **`restoreSessionState()`** (lines 1622–1648) validates timestamp formats, enforces `MAX_SESSIONS` limits, and leaves sessions dormant until incoming HTTP requests trigger lazy transport initialization.
- **Dormant sessions** retain tenant context (`n8nApiUrl`, `n8nApiKey`, `instanceId`) across container restarts without maintaining active server objects, enabling seamless multi-tenant continuity in ephemeral environments.
- **Security audit logging** tracks all persistence operations, while automatic cleanup every 5 minutes prevents expired sessions from consuming resources.

## Frequently Asked Questions

### What headers identify a tenant session in n8n-MCP?

In HTTP mode, the server identifies tenants through the `x-n8n-url`, `x-n8n-key`, and optional `x-instance-id` headers. Subsequent requests within the same session must include the `mcp-session-id` header to match against stored `sessionMetadata` and `sessionContexts` maps.

### How does n8n-MCP handle sessions when a container restarts?

When `ENABLE_MULTI_TENANT=true`, the host calls `exportSessionState()` before shutdown to capture tenant contexts, then encrypts and stores the payload externally. On startup, `restoreSessionState()` repopulates internal maps without creating transport objects; these are instantiated lazily when clients reconnect, allowing seamless continuity of n8n instance connections across container lifecycles.

### Why are API keys stored in plaintext during session export?

The `exportSessionState()` method in [`src/http-server-single-session.ts`](https://github.com/czlonkowski/n8n-mcp/blob/main/src/http-server-single-session.ts) intentionally exports `n8nApiKey` as plain text to keep the core library agnostic of specific encryption implementations. The code emits an explicit security warning logging event, requiring the deployment operator to encrypt the payload (for example, using AES-256-GCM or a KMS) before persisting to disk, S3, or etcd.

### What limits the number of restored sessions?

The `restoreSessionState()` method checks `Object.keys(this.sessionMetadata).length >= MAX_SESSIONS` before processing each entry, silently dropping excess records to prevent memory exhaustion. This guard ensures that even if a persistence backend contains thousands of stale sessions, the active process only loads a configurable maximum into resident memory.