Guide · Authentication

MCP server API key management

API keys are the simplest authentication mechanism for MCP servers and the right choice for server-to-server integrations, automated agents, and monitoring probes. "Simple" does not mean "easy to get right" — the same four mistakes appear in production MCP servers repeatedly: UUIDs used as keys (insufficient entropy), keys stored in plaintext (one database leak exposes all callers), no rotation workflow (keys live forever), and no scoping (one key controls everything). This guide covers the correct implementation for each step of the API key lifecycle: generation, storage, validation, rotation, and scoping.

TL;DR

Generate keys with crypto.randomBytes(32).toString('hex') (256 bits of entropy). Use a mcp_live_<prefix>_<secret> format so keys are identifiable in logs. Store a SHA-256 hash of the full key in the database — never the plaintext. For validation, hash the provided key and compare hashes using timingSafeEqual. Rotation: issue a new key, set an overlap window (48 hours), then revoke the old key. Give each key the minimum scope needed — probe keys get health:ping only.

Key generation: why UUIDs are not enough

A UUID (e.g. 550e8400-e29b-41d4-a716-446655440000) has approximately 122 bits of entropy — enough to prevent collision, not enough to resist brute-force in an offline attack against a leaked hash. A crypto.randomBytes(32) key has 256 bits of entropy, making offline brute-force computationally infeasible even against a leaked hash database.

import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';

export function generateApiKey(prefix: 'live' | 'test' | 'probe'): {
  key: string;           // returned to the caller once; never stored
  keyPrefix: string;     // stored in DB — used for lookups and logs
  keyHash: string;       // stored in DB — used for validation
} {
  // 32 bytes = 256 bits of entropy; hex = 64 characters
  const secret    = randomBytes(32).toString('hex');
  const keyPrefix = randomBytes(4).toString('hex'); // 8-char identifier

  // Format: mcp_{env}_{prefix}_{secret}
  // The prefix makes keys identifiable in logs and git-secret scans
  const key = `mcp_${prefix}_${keyPrefix}_${secret}`;

  const keyHash = createHash('sha256').update(key).digest('hex');

  return { key, keyPrefix, keyHash };
}

// Example output:
// key:       "mcp_live_a3f8c201_b1e4d9f2a8c3e5b7d4f1a9c2e8b5d7f3a1c4e6b8d2f5a7c9e3b6d8f0a2c4e7b9"
// keyPrefix: "a3f8c201"
// keyHash:   "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"

The prefix (mcp_live_) makes the key identifiable in three important contexts: (1) if a key leaks in a git commit, tools like trufflehog can detect it with a custom pattern; (2) if a key appears in a log line, it is immediately recognizable as an MCP API key (not a credit card number or social security number); (3) the live/test/probe suffix prevents accidentally using a test key in production.

Database schema for API keys

-- SQLite schema for API key storage
CREATE TABLE api_keys (
  id          TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
  key_prefix  TEXT NOT NULL UNIQUE,    -- first 8 chars after env prefix; used for lookups
  key_hash    TEXT NOT NULL UNIQUE,    -- SHA-256 of the full key; used for validation
  tenant_id   TEXT,                    -- NULL for single-tenant servers
  scopes      TEXT NOT NULL,           -- JSON array: '["data:read","data:write"]'
  label       TEXT NOT NULL,           -- human label: "Production agent", "CI pipeline"
  created_at  TEXT NOT NULL DEFAULT (datetime('now')),
  last_used_at TEXT,                   -- updated on each successful validation
  expires_at  TEXT,                    -- NULL for non-expiring keys
  revoked_at  TEXT                     -- NULL until explicitly revoked
);

-- Index for fast lookup by prefix (prefix is logged; used to find the hash)
CREATE INDEX idx_api_keys_prefix ON api_keys (key_prefix) WHERE revoked_at IS NULL;

The key_hash field is all that is needed for validation — but it is not useful for operations. The key_prefix is what you store in structured logs so you can answer "which key was used for this session?" without exposing the full key or its hash. When a key is revoked, set revoked_at to the current timestamp — do not delete the row. The row is needed for audit investigation ("was this key used after its compromise date?").

Validation with constant-time comparison

export async function validateApiKey(
  providedKey: string,
  db: Database
): Promise<ApiKeyIdentity | null> {
  // Extract the prefix from the key format: mcp_{env}_{prefix}_{secret}
  const parts = providedKey.split('_');
  if (parts.length !== 4 || parts[0] !== 'mcp') {
    return null; // Malformed key — not ours
  }

  const prefix = parts[2]; // e.g. "a3f8c201"

  // Look up by prefix — fast index scan, no hash computation needed yet
  const row = db.prepare(`
    SELECT key_hash, scopes, tenant_id, id
    FROM api_keys
    WHERE key_prefix = ?
      AND revoked_at IS NULL
      AND (expires_at IS NULL OR expires_at > datetime('now'))
  `).get(prefix) as ApiKeyRow | undefined;

  if (!row) {
    return null; // Unknown prefix — invalid key
  }

  // Hash the provided key and compare with stored hash using constant-time equality
  const providedHash = createHash('sha256').update(providedKey).digest('hex');
  const storedHash   = row.key_hash;

  // Both hashes are 64-character hex strings (same length) — timingSafeEqual is safe
  const providedBuf = Buffer.from(providedHash, 'hex');
  const storedBuf   = Buffer.from(storedHash, 'hex');

  if (!timingSafeEqual(providedBuf, storedBuf)) {
    return null; // Hash mismatch — invalid key
  }

  // Update last_used_at asynchronously — don't block the request
  setImmediate(() => {
    db.prepare('UPDATE api_keys SET last_used_at = datetime(\'now\') WHERE id = ?').run(row.id);
  });

  return {
    keyId:    row.id,
    keyPrefix: prefix,
    tenantId: row.tenant_id,
    scopes:   JSON.parse(row.scopes) as string[],
  };
}

The prefix-first lookup pattern avoids computing a hash for every validation request — you only hash the provided key if the prefix matches a real key in the database. An attacker cannot use prefix-based enumeration (checking one prefix at a time) because the prefix is derived from the key they already hold; without the secret portion, knowing the prefix is useless.

Key rotation with overlap window

Key rotation replaces a key without dropping the sessions and integrations using the old key. The overlap window is the period during which both old and new keys are valid:

// Step 1: Issue the new key (do not revoke the old key yet)
const { key: newKey, keyPrefix, keyHash } = generateApiKey('live');
db.prepare(`
  INSERT INTO api_keys (key_prefix, key_hash, tenant_id, scopes, label)
  VALUES (?, ?, ?, ?, ?)
`).run(keyPrefix, keyHash, tenantId, oldKey.scopes, `${oldKey.label} (rotation replacement)`);

// Step 2: Return the new key to the caller — they update their configuration
// The new key is valid immediately; the old key is also still valid

// Step 3: After overlap window (48 hours), revoke the old key
// "48 hours" = enough time for all callers to update their configuration
db.prepare(`
  UPDATE api_keys SET revoked_at = datetime('now') WHERE id = ?
`).run(oldKeyId);

// Step 4: Verify no traffic on the old key before revoking
// Check last_used_at — if it's within the last 5 minutes, extend the overlap window

Check last_used_at before revoking. If any caller used the old key within the last 5 minutes, they have not yet updated their configuration — extend the overlap window and notify the caller. Only revoke when last_used_at is older than the overlap window, confirming the old key is no longer in active use.

ScenarioOverlap windowWhy
Automated agent (can hot-reload config)1 hourAgent can update immediately after receiving new key
CI/CD pipeline (next deploy picks up new secret)24 hoursNext deploy must run before old key is revoked
MCP server embedded in a shipped binary30 daysUsers must upgrade the binary; rollout is slow
Emergency rotation (key compromised)0 — revoke immediatelyAttacker has the old key; accept disruption

Per-key scoping

Each API key in the database has a scopes column — a JSON array of permission strings. Issue keys with the minimum scope required for their purpose:

Key purposeScopesRationale
AliveMCP uptime probe["health:ping"]Only calls the health_ping tool
Read-only integration (dashboard)["data:read"]Cannot modify data even if compromised
Automation agent["data:read", "data:write"]Reads and writes, no deletion
Admin CLI["data:read", "data:write", "data:delete", "admin"]Full access — restrict to human operators only
// In the HTTP middleware, set identity scopes from the API key's stored scopes
const identity = await validateApiKey(providedKey, db);
if (!identity) {
  return res.status(401).json({ error: 'invalid_api_key' });
}

// The identity includes only the scopes assigned to this specific key
res.locals.identity = {
  type:     'api_key',
  sub:      identity.keyId,
  keyPrefix: identity.keyPrefix,
  tenantId: identity.tenantId,
  scopes:   identity.scopes, // from database — not from the key itself
};

// RBAC enforcement in tool handlers checks context.identity.scopes
// A probe key with only ["health:ping"] cannot call data:read tools

Storing scopes in the database — not in the key itself — means you can adjust a key's scopes without issuing a new key. If an integration turns out to need one more permission, add it to the database row. If a key is over-permissioned (you gave data:write but it only reads), reduce its scopes without rotation. See MCP server RBAC for the enforcement layer that reads these scopes in tool handlers.

Rate limiting per API key

API keys for different callers should have independent rate limit buckets. A noisy caller should not exhaust the rate limit for other callers. After validating the key and extracting identity.keyId, use it as the rate limit bucket key:

// In the middleware, after API key validation
const rateLimitKey = `api_key:${identity.keyId}`;
const allowed = await rateLimiter.check(rateLimitKey, {
  windowMs: 60 * 1000,    // 1 minute
  maxRequests: 100,        // 100 requests per minute per key
});

if (!allowed) {
  return res.status(429).json({
    error: 'rate_limit_exceeded',
    retry_after_seconds: allowed.retryAfterMs / 1000,
  });
}

Set the rate limit generously for machine-to-machine integrations (100–1000 req/min) and tightly for probe keys (10 req/min — AliveMCP pings every 60 seconds, so 10/min is 10x the expected rate). See MCP server rate limiting for the full per-session and per-key rate limiting implementation.

Related questions

Should I use bcrypt or SHA-256 to hash API keys?

SHA-256. Bcrypt is the right choice for password hashing because passwords are low-entropy and an attacker with a leaked hash can brute-force common passwords. API keys generated with 256 bits of entropy cannot be brute-forced regardless of the hash algorithm — SHA-256 is fine, and it is orders of magnitude faster (important for per-request validation). Bcrypt adds 100ms+ per validation, which is a significant overhead for a high-traffic MCP server. Never use MD5 or SHA-1, which have known collision vulnerabilities (not exploitable for this use case, but poor hygiene).

How do I handle the "show the key once" constraint?

Generate the key, return it to the caller in the API response or creation UI, and immediately store only the hash in your database. If the caller loses the key, they cannot recover it — issue a new key instead. Make this constraint explicit in your UI: "This key will not be shown again. Copy it now." Do not implement a "reveal key" feature, even with re-authentication — if the database is queried for the plaintext key, you're storing it somewhere. The hash is the only stored value.

How do I detect API key leaks in customer repositories?

Register a custom secret scanning pattern with GitHub (if your customers use GitHub) that matches your key format: mcp_live_[0-9a-f]{8}_[0-9a-f]{64}. GitHub will notify you when it finds this pattern in any public repository. When notified, immediately revoke the leaked key and contact the customer. If you manage an enterprise MCP server, GitHub Advanced Security can scan private repositories too. See MCP server security monitoring for the alert pipeline around unexpected key usage that may indicate a leak.

Can one API key be used across multiple MCP server deployments (staging and production)?

No — always issue separate keys for separate deployments. A key that works in staging and production means a staging breach compromises production credentials. Use the key prefix convention (mcp_live_ vs. mcp_test_) to enforce this: your server should reject keys with the wrong environment prefix outright, before even checking the hash.

Further reading