ExMCP Security Guide

Copy Markdown View Source

Security is handled at the protocol edge: HTTP clients and Plug/Phoenix servers use authentication, TLS, origin checks, and CORS; stdio relies on subprocess isolation; BEAM-local relies on local process ownership and application-level authorization.

Outbound requests additionally pass through a trust boundary that strips credentials from, and requires consent for, origins the application has not declared. Its defaults are fail-closed, so read Outbound Requests before pointing a client at a remote server.

Security Matrix

FeatureStreamable HTTPstdioBEAM-local (:beam)
Bearer/custom headersYesNoApp-level metadata/handler logic
OAuth 2.1 flowsYesNoNo
TLSYesNoOnly through distributed Erlang if you add it
Origin/CORS checksYesNoNot applicable
DNS rebinding protectionOrigin + Host allow-lists (:allowed_origins, :allowed_hosts, ExMCP.Plugs.DnsRebinding)Not applicableNot applicable
Outbound origin trust (SecurityGuard)YesYes, for resources/* URIsNot applicable
Process isolationServer processSubprocessLocal BEAM process

ExMCP.Transport.SecurityGuard runs on every outbound HTTP POST — the JSON-RPC channel — and on the URIs of resources/read / resources/list requests sent over stdio. It classifies the target URL against :trusted_origins and, for anything that is not trusted:

  1. removes credential headers (authorization, cookie, x-api-key, x-auth-token, x-csrf-token) so a token issued for one origin is never passed through to another, and
  2. asks the configured :consent_handler to approve the origin, caching the decision until it expires.

The defaults are fail-closed and this bites first-time users. :trusted_origins is loopback-only and :consent_handler is ExMCP.ConsentHandler.Deny, so a client pointed at a server that is not on localhost has its Authorization header stripped and the request denied with consent_denied. Declare the servers your application talks to:

config :ex_mcp, :security,
  trusted_origins: ["https://mcp.example.com"]

A trusted origin is exempt from both checks — it is never stripped and never prompts for consent. Consent then applies only to origins your application did not declare. Prefer this over disabling a control. The SecurityGuard logs the exact setting to add whenever it strips credentials or blocks a request, so this failure mode is loud rather than silent.

SettingDefaultEffect
:trusted_origins["localhost", "127.0.0.1", "::1"]Same security domain. "*.example.com" matches subdomains.
:consent_handlerExMCP.ConsentHandler.DenyConsulted for untrusted origins. CLI prompts; Web defers to an out-of-band flow.
:consent_ttl24 hours (milliseconds)Lifetime of a cached consent decision.
:enable_token_passthrough_preventiontrueSet false to forward credentials to untrusted origins.
:enable_user_consent_validationtrueSet false to skip the consent handler entirely.

Every consent decision path fails closed: a denial, an error, an unrecognised handler return value, or an expiry that cannot be interpreted all block the request.

ExMCP.ConsentHandler implementations return an expiry. Use one of the explicit forms — DateTime, {:ttl, seconds}, {:unix, seconds}, or {:monotonic, seconds}:

def request_consent(_user_id, _origin, context) do
  {:ok, {:ttl, Map.get(context, :consent_ttl, 3600)}}
end

A bare integer is still read as System.monotonic_time(:second) for backwards compatibility. Returning Unix epoch seconds as a bare integer is the easy mistake — it would otherwise grant consent for decades — so implausible values (already past, or more than 365 days out) are rejected and the request fails.

HTTP Client Security

{:ok, client} =
  ExMCP.Client.start_link(
    transport: :http,
    url: "https://api.example.com/mcp",
    headers: [{"Authorization", "Bearer #{token}"}],
    security: %{
      validate_origin: true,
      allowed_origins: ["https://app.example.com"],
      tls: %{verify: :verify_peer}
    }
  )

For OAuth flows, use the authorization modules or :auth / :auth_provider options on the HTTP transport.

TLS

HTTPS connections verify the peer against the OS trust store with TLS 1.2/1.3 and HTTPS hostname matching by default. tls: %{verify: :verify_none} is accepted for local development against self-signed certificates, but it makes the connection unauthenticated — encrypted, yet open to an active man-in-the-middle — and ExMCP logs a warning whenever it is configured.

HTTP Server Security

Use Plug/Phoenix pipelines for server-side concerns:

pipeline :mcp do
  plug ExMCP.Plugs.DnsRebinding
  plug MyApp.VerifyRequestSignature
  plug MyApp.RequireMCPToken
end

scope "/mcp" do
  pipe_through :mcp

  forward "/", ExMCP.HttpPlug,
    handler: MyApp.MCPServer,
    server_info: %{name: "my-app", version: "1.0.0"},
    sse_enabled: true,
    cors_enabled: true
end

Keep request authentication and authorization at the HTTP edge. Keep tool/resource authorization in handler code when it depends on the specific tool, resource URI, user, tenant, or project.

DNS rebinding protection

Protection is Host-allow-list based and is on by default for localhost servers, which are the prime rebinding target. ExMCP.HttpPlug provides three complementary controls:

  • Host allow-list (:allowed_hosts): requests whose Host header is not listed are rejected with 421 before any routing or handler work. Ports are ignored and IPv6 hosts match with or without brackets ([::1]:8080 matches "[::1]" and "::1"). Servers started through ExMCP.Server.Transport with a localhost bind get ["localhost", "127.0.0.1", "[::1]", "::1"] automatically; an explicit :allowed_hosts always wins. When you mount ExMCP.HttpPlug yourself — in a Phoenix forward, say — set :allowed_hosts explicitly to the hostnames the server is reachable under rather than relying on the default.
  • Origin allow-list (:validate_origin, default true, plus :allowed_origins): requests that carry an Origin header are rejected with 403 unless the origin is listed (or :allowed_origins is :any). Requests without an Origin header are allowed, because non-browser clients do not send one — so the Origin check alone is not rebinding protection, and the Host allow-list is what closes that gap. There is no "same origin as the Host header" fallback: under DNS rebinding the Host header is attacker-controlled, so such a comparison would always pass.
  • ExMCP.Plugs.DnsRebinding: a standalone plug for Phoenix/Plug pipelines that enforces a Host allow-list (default: loopback names only) in front of any downstream plugs.

Session ids supplied via mcp-session-id / legacy x-session-id headers are validated (max 128 bytes, A-Za-z0-9._~+/=-) and malformed values are rejected with 400 without being echoed back.

stdio Security

stdio is appropriate when the MCP server process is trusted by the application that launches it.

Best practices:

  • Use absolute commands or controlled PATHs for production.
  • Set cd and env explicitly.
  • Do not log to stdout; stdout is protocol traffic.
  • Run subprocesses with the least privileges needed.
  • Validate tool arguments before touching filesystem or network resources.

BEAM-Local Security

transport: :beam is for trusted local Elixir processes:

{:ok, server} = MyServer.start_link(transport: :beam)   # requires use of DSL, or use HandlerServer
{:ok, client} = ExMCP.Client.start_link(transport: :beam, server: server)

There is no wire-level authentication inside a single VM. Enforce access with normal application structure: supervision boundaries, process ownership, handler authorization, and explicit context in tool arguments or handler state.

If you cross Erlang nodes in your own application, secure distributed Erlang separately with strong cookies, private networks, and TLS distribution where appropriate.

Validation

Use public validation helpers where available:

ExMCP.Security.Validation.validate_config(security_config)

Use ExMCP.Content.Validation and handler-side schema checks for tool/resource input validation.

Verifying JWTs

ExMCP.Authorization.JWT.verify/2 checks the signature only — an expired token verifies fine. Use verify_and_validate/3 (or validate_claims/2) for anything that makes an authorization decision:

{:ok, claims} =
  ExMCP.Authorization.JWT.verify_and_validate(token, jwks,
    iss: "https://auth.example.com",
    aud: "https://mcp.example.com"
  )

exp is required and must be numeric; nbf and iat must be numeric when present. Time comparisons allow 30 seconds of clock skew, tunable with :leeway. Pass require_exp: false only for a token profile that genuinely has no expiry. Only asymmetric algorithms are accepted (RS/PS/ES) — none and the HMAC family are rejected, so an attacker cannot swap the header's alg.

iss and aud are checked only when you supply the expected values; always supply them when validating tokens from an identity provider.

Best Practices

  • Use HTTPS in production.
  • Prefer short-lived bearer tokens or OAuth flows.
  • Do not send secrets through tool arguments unless the tool explicitly needs them.
  • Validate resource URIs and file paths before access.
  • Return safe error messages to clients; log sensitive details internally.
  • Put coarse-grained HTTP checks in Plug pipelines and fine-grained MCP checks in handlers.
  • Monitor telemetry for unusual request, auth, or transport failure patterns.

Common Issues

{:security_violation, %ExMCP.Transport.SecurityError{type: :consent_denied}}

The server's origin is not in :trusted_origins and the default consent handler denied it. Add the origin — see Outbound Requests.

401/403 from HTTP server

Check headers, auth, or auth_provider on the client and the server's Plug auth pipeline. If the request never carried the credential at all, look for a SecurityGuard: removed credential headers warning in the log: the target origin is not trusted, so the Authorization header was stripped before the request went out.

CORS failure

Configure the Phoenix/Plug pipeline or ExMCP.HttpPlug CORS options for the browser origin.

BEAM-local access control

Pass explicit application context into the handler state or arguments and reject unauthorized tool/resource calls inside the handler.