Skip to content

[gateway] Per-user MCP proxy auth via signed header (replace spoofable x-user-id) #4

@krrish-berri-2

Description

@krrish-berri-2

For the LAP gateway repo (litellm-agent-platform-2); issues are disabled there, filing here.

Problem — identity is client-supplied (spoofable)

The dynamic MCP proxy (src/http/mcp_registry/proxy.rs::dynamic_mcp, route /{mcp_server_name}/mcp) resolves which user's vault credentials to use from caller_user_id(), which reads the x-user-id request header and falls back to "default":

// src/http/mcp_registry/mod.rs
pub(super) fn caller_user_id(headers: &HeaderMap, _state: &AppState) -> String {
    headers.get("x-user-id").and_then(...).unwrap_or_else(|| "default".to_owned())
}

resolve_variables() then fetches per-user creds keyed mcp_var:{server_id}:{var} owned by that user_id (e.g. Composio COMPOSIO_USER_ID / COMPOSIO_API_KEY).

The proxy is reached by Anthropic (managed agents) over the public internet when an agent uses a tool. Auth is only require_any_gateway_key (a flat key allowlist — keys carry no owner identity). So anyone holding a gateway key can set x-user-id: <victim> and read that user's vault secrets. Identity must come from the authenticated principal, not a client header.

There is currently no key→owner mapping (GatewayApiKeyStore::accepts() is a flat set; master key = admin). So today there is no secure way to do per-user resolution on the dynamic path.

Proposed fix — signed per-user token

When the gateway builds an agent's mcp_servers[] URL (in runtime_inputs::mcp_servers), embed a signed assertion of the on-behalf-of user, e.g.:

  • a short HMAC/JWT signed with the gateway's master_key, claims { user_id, server_id, exp }, carried either as the entry's authorization_token (Bearer) or a URL query param (?u=<token>).

On the inbound proxy call, dynamic_mcp:

  1. verify the signature (reject if invalid/expired) — this authenticates and identifies.
  2. take user_id from the verified token, never from x-user-id.
  3. resolve mcp_var:{server_id}:{user_id}:* as today.

This mirrors how platform MCPs already derive identity from session_id server-side — generalize it to a tamper-proof signed token so Anthropic can forward it back and the gateway trusts it.

Acceptance

  • caller_user_id (dynamic path) no longer trusts x-user-id; identity comes from a verified signed token.
  • Forging another user's identity is impossible without the signing key.
  • Per-user Composio (and other per_user var) resolution works for non-default owners.
  • Backwards-compatible default (single-owner) path still works.

Related

  • The runtime currently forwards the raw ${…} upstream URL to Anthropic (separate bug → mcp_servers.0.url: value must be a valid URI); the fix routes registered servers through this proxy, which is what makes this auth design necessary.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions