Skip to content

How It Works

MCPGate is built around three ideas: keys that carry their own lookup ID, storage that never leaks, and a verify path fast enough to run on every request.


Every key MCPGate generates follows this format:

mcp_sk_<public_id>_<secret>
│ │ │
│ │ 64 hex chars (32 random bytes)
│ 16 hex chars (8 random bytes) — stored in DB, used for lookup
configurable prefix (default: mcp_sk_)

Example:

mcp_sk_3f8a1c2e9b4d7f6a_4a7b2c9e1d3f8a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b

The public ID (3f8a1c2e9b4d7f6a) is indexed in the database. When a key arrives for verification, MCPGate extracts it directly from the key string — no full table scan needed.


public_id = secrets.token_hex(8) # 16 hex chars — embedded in key, stored in DB
secret = secrets.token_hex(32) # 64 hex chars — never stored
plain_text_key = f"{prefix}{public_id}_{secret}"
# e.g. mcp_sk_3f8a1c2e9b4d7f6a_4a7b2c9e...

The plain-text key is returned once in the API response and never stored. What’s stored:

FieldValuePurpose
public_id3f8a1c2e9b4d7f6aIndexed — fast O(1) lookup
key_hash$2b$12$...bcrypt hash of the pre-hashed key
prefixmcp_sk_3f8a1c..._...5bDisplay string — safe to show users

Bcrypt has a 72-byte input limit. A full MCPGate key is ~88 bytes — too long. The solution is a two-step hash:

plain_text_key
│
▼
SHA-256(key) → 64-char hex string (always fits in bcrypt's 72-byte limit)
│
▼
bcrypt(sha256_hex, salt) → stored key_hash

This means:

  • bcrypt’s 72-byte truncation bug can never trigger
  • The stored hash is computationally infeasible to reverse
  • Every key has a unique bcrypt salt (auto-generated per key)

On verification, the same two steps run on the incoming key and the result is compared against the stored hash using bcrypt.checkpw.


Incoming request with key: mcp_sk_3f8a1c2e9b4d7f6a_4a7b2c...
│
┌────────────────────┘
│ 1. Extract public_id from key string
▼
SELECT * FROM api_keys WHERE public_id = '3f8a1c2e9b4d7f6a'
AND is_active = TRUE
│
│ 2. O(1) indexed lookup (public_id has a DB index)
▼
bcrypt.checkpw(sha256(incoming_key), stored_key_hash)
│
│ 3. Cryptographic verification
▼
Check expires_at < now()
│
│ 4. Expiry check
▼
UPDATE api_keys SET last_used_at = now() WHERE id = ...
│
│ 5. Audit trail update
▼
{"valid": true, "user_id": "...", "scopes": {...}}

The verify endpoint does not require admin authentication — it’s designed to be called on every MCP request at high frequency. The public ID extraction keeps it to a single indexed DB read.


Scopes are an arbitrary JSON object stored with each key:

{"read": true, "write": false, "tools": ["search", "code"]}

MCPGate stores and returns them verbatim — your application enforces them. This gives you full flexibility without MCPGate needing to understand your permission model.


When creating a key with expires_in_days, MCPGate calculates an absolute UTC timestamp:

expires_at = now() + timedelta(days=N)

On every verify, if expires_at is set and in the past, the key is rejected with "error": "Key expired". The key record is not automatically deleted — use the delete endpoint to clean up expired keys.


The KEY_PREFIX env var (default: mcp_sk_) is prepended to every generated key. Change it to match your product:

KEY_PREFIX=myapp_sk_ → myapp_sk_3f8a1c2e_...
KEY_PREFIX=agent_ → agent_3f8a1c2e_...

The verify endpoint reads KEY_PREFIX at startup to validate incoming key format. Keys created with one prefix can’t be verified with a different prefix setting.