# ExMCP Security Guide

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](#outbound-requests-trusted-origins-and-consent) before
pointing a client at a remote server.

## Security Matrix

| Feature | Streamable HTTP | stdio | BEAM-local (`:beam`) |
|---------|-----------------|-------|----------------------|
| Bearer/custom headers | Yes | No | App-level metadata/handler logic |
| OAuth 2.1 flows | Yes | No | No |
| TLS | Yes | No | Only through distributed Erlang if you add it |
| Origin/CORS checks | Yes | No | Not applicable |
| DNS rebinding protection | Origin + Host allow-lists (`:allowed_origins`, `:allowed_hosts`, `ExMCP.Plugs.DnsRebinding`) | Not applicable | Not applicable |
| Outbound origin trust (`SecurityGuard`) | Yes | Yes, for `resources/*` URIs | Not applicable |
| Process isolation | Server process | Subprocess | Local BEAM process |

## Outbound Requests: Trusted Origins and Consent

`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:

```elixir
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.

| Setting | Default | Effect |
|---------|---------|--------|
| `:trusted_origins` | `["localhost", "127.0.0.1", "::1"]` | Same security domain. `"*.example.com"` matches subdomains. |
| `:consent_handler` | `ExMCP.ConsentHandler.Deny` | Consulted for untrusted origins. `CLI` prompts; `Web` defers to an out-of-band flow. |
| `:consent_ttl` | 24 hours (milliseconds) | Lifetime of a cached consent decision. |
| `:enable_token_passthrough_prevention` | `true` | Set `false` to forward credentials to untrusted origins. |
| `:enable_user_consent_validation` | `true` | Set `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.

### Writing a consent handler

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

```elixir
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

```elixir
{: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.

### OAuth credential isolation

Modern pre-registered clients bind their credentials to an exact
authorization-server issuer through `credential_issuer`. ExMCP rejects
mismatches without normalizing trailing slashes or paths, validates discovered
AS metadata against the issuer that led to it, and never resolves the client
secret before that check succeeds. CIMD client IDs are the deliberate portable
exception defined by the protocol.

Persistent hosts should implement `ExMCP.Authorization.CredentialStore` using
an encrypted database or platform keychain. Registrations are keyed by issuer
and client ID; tokens additionally include resource/audience, subject or client
identity, and granted scopes. Credential values redact secrets from `Inspect`,
and adapter failures exposed through the OAuth flow omit adapter-provided error
details so a badly behaved store cannot place token material in logs. Unkeyed
legacy records require an explicit migration after their original issuer is
independently verified.

### OAuth metadata SSRF protection

Treat every URL learned during OAuth discovery as attacker-controlled. ExMCP
routes CIMD, Protected Resource Metadata, OIDC/RFC 8414 authorization-server
metadata, and JWKS requests through `ExMCP.Authorization.MetadataFetcher`.
The boundary requires HTTPS; forbids URI userinfo and fragments; bounds DNS,
connection and request time; bounds per-response and aggregate redirect bytes;
rejects compression; and follows only a bounded, cycle-free redirect chain.

DNS is resolved again for every hop. If any returned IPv4, IPv6, or IPv4-mapped
IPv6 address is private, loopback, link-local, reserved, documentation-only, or
otherwise non-public, the request fails before connection. The default client
connects to one validated address directly while retaining the URL hostname for
TLS SNI, certificate verification, and the HTTP host value. This closes the
validation/re-resolution gap used by DNS rebinding.

Redirects are same-origin unless an operator explicitly lists an exact HTTPS
destination in `allowed_redirect_origins`; an allowed destination is still
re-resolved and revalidated. Metadata requests use fixed non-secret headers and
never forward bearer tokens, cookies, client credentials, MCP session headers,
or application transport headers. URL-only custom client adapters are rejected
because they cannot prove address pinning.

### 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:

```elixir
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,
    protocol_mode: :prefer_modern,
    server_info: %{name: "my-app", version: "1.0.0"},
    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.

### MRTR request state

Modern multi-round requests use an ExMCP-owned, versioned AES-256-GCM envelope.
The authenticated payload binds the immutable request digest, expected input
IDs, round, protocol version, endpoint, capability fingerprint, principal, and
tenant. It contains only bounded JSON application state—never bearer tokens or
runtime secrets.

Load the 32-byte key ring at runtime and retain decrypt-only old keys for at
least the maximum token TTL plus clock skew. `active_key_id` selects the only
encryption key; `revoked_key_ids` immediately prevents use of a compromised
key. Every node that may resume a request needs the same ring.

Rotate the ring in three separately observable deployments:

1. Add the new key to every node while the old key remains `active_key_id`.
2. After every serving node can decrypt both key IDs, roll `active_key_id` to
   the new key. Mixed snapshots remain interoperable during this deployment.
3. Remove the old decrypt-only key only after the greatest configured token
   TTL plus clock skew has elapsed since the final node stopped encrypting
   with it.

Treat each key-ring snapshot as one atomic runtime configuration value; never
roll an active key before its key material has reached every node. For an
emergency compromise, add the old ID to `revoked_key_ids` everywhere instead
of waiting for normal retirement. In-flight tokens using that ID then fail
closed and callers must restart the operation.

AEAD prevents tampering but not replay. Side-effecting resumptions should set
`require_replay_protection: true` and configure a shared replay-cache adapter.
`ExMCP.Server.ReplayCache.ETS` is atomic but node-local and is therefore only
appropriate for single-node deployments. Without an adapter, handlers must
treat `RequestContext.delivery_semantics == :at_least_once` accordingly.

### Modern subscription streams

Subscription registrations store stable principal and tenant identifiers,
never bearer tokens. Filter authorization runs before acknowledgment and
publication authorization runs again for every event. Configure a bounded
maximum stream lifetime so credentials are periodically re-evaluated, and use
a cluster-aware adapter with PubSub fan-out for multi-node HTTP deployments.
The bundled ETS adapter and listener supervisor are node-local. Modern HTTP
streams treat an unsuccessful response chunk as cancellation, immediately
remove the listener registration, and send no further events. Periodic SSE
comment keepalives make this cleanup happen even when no application events
are available to expose a disconnected peer.

The PubSub adapter distributes only method names and protocol parameters; it
does not distribute credentials or registration records. Each node matches
against its own honoured filters and each listener re-runs publication
authorization using its stored stable identity. Use a deployment-specific
topic, protect access to the PubSub cluster, and do not place secrets in
notification parameters.

### 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.

On legacy Streamable HTTP paths, 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. MCP 2026-07-28 is stateless; do not use a session header as
authentication, authorization context, tenant identity, or modern request
correlation.

## JSON Schema References and Resource Limits

JSON Schema is executable input: resolving a reference can cause network I/O,
and adversarial composition can consume excessive CPU or memory. ExMCP applies
the same policy to content helpers, tool argument validation, DSL output
schemas, the deprecated tools API, and the dynamic tool registry.

- Only same-document fragment references (`#` and `#/...`) are accepted by
  default. File, recursive cross-document, and dynamic cross-document
  references are always rejected.
- HTTP(S) `$ref` fetching requires an explicit non-empty host allowlist.
- Configuring ExJsonSchema's process-wide `:remote_schema_resolver` does not
  bypass this boundary.
- Encoded bytes, structural depth, schema-object count, composition depth,
  resolution time, and validation time are bounded.
- Policy errors do not include the rejected reference, avoiding accidental
  disclosure of credentials or sensitive internal URLs.

When network fetching is enabled, each redirect is independently allowlisted
and DNS-resolved. Any non-public IPv4/IPv6 answer rejects the target, and the
HTTP client connects to the approved IP while retaining the hostname for TLS
certificate verification and SNI. Userinfo and credentials are forbidden;
responses, document graphs, redirects, and deadlines are bounded; compressed
responses and proxies are rejected. Cross-document fetch cycles fail closed.

Remote documents live only inside the current compilation and are never put in
a global cache. Fetch audit logs include the allowed host plus hashes of the URI
and trust partition, not paths, queries, credentials, or raw partition IDs.

The complete limits, defaults, and opt-in example are in the
[Configuration Guide](CONFIGURATION.md#json-schema-resource-policy). Prefer
embedding definitions in the local schema whenever practical.

## Trace Context and Baggage

Treat `traceparent`, `tracestate`, and `baggage` in MCP `_meta` as untrusted
wire input. ExMCP validates these fields before exposing them to handlers or
putting them on outbound modern requests. Malformed values reject request
metadata instead of being silently forwarded, and the sanitized `_meta` no
longer contains baggage members removed by policy.

Baggage is default-deny. Incoming baggage is subject to total-byte,
baggage-byte, member-count, syntax, and duplicate-key checks before the
allowlist is applied, so a disallowed member cannot bypass resource limits.
Keep the allowlist short and limited to non-secret, low-cardinality identifiers;
baggage can cross process and service trust boundaries and may be recorded by
observability infrastructure.

ExMCP only transports the validated strings. It does not install an
OpenTelemetry SDK, create spans, or attach remote context to global/process
state. Applications that choose to continue a trace must do so explicitly from
`ExMCP.Server.RequestContext.trace_context` using their own trusted telemetry
integration. See the
[configuration guide](CONFIGURATION.md#opentelemetry-metadata-policy) for the
defaults and client example.

## Durable Tasks

Modern task IDs may act as bearer handles to stored execution state. Generate
them with cryptographic entropy; `ExMCP.Tasks.Task.new/3` does this by default,
but an application that supplies `:id` assumes that responsibility. Bind every
stored task to the authenticated principal, tenant, resource/audience, and
other authorization context needed by the deployment, and repeat that check on
every `tasks/get`, `tasks/update`, and `tasks/cancel` request. Possession of a
valid-looking task ID is not authorization.

Persist the task and its authorization binding before returning a
`resultType: "task"` handle. A successful creation response promises that an
immediate `tasks/get` can find the task, including after a worker, connection,
or client restart. Enforce TTL cleanup without reassigning identifiers, and use
atomic or concurrency-controlled transitions so late updates cannot overwrite
a terminal state.

`ExMCP.Tasks.Store.ETS` provides this guarantee only while its owning ExMCP
application remains running. It deliberately returns the same error for a
missing task and an ownership mismatch. Use a shared, restart-persistent
`ExMCP.Tasks.Store` implementation when multiple nodes can serve task requests
or server-restart recovery is required.

Treat task `inputRequests` with the same consent and trust policy as the
corresponding elicitation, sampling, or roots request. Validate each
`inputResponses` key against the currently outstanding request set, make
retries idempotent, and never accept a response for a different task. Task
notifications carry full state and may contain result or error data; publish
them only through an authorized `subscriptions/listen` stream.

The modern protocol intentionally has no `tasks/list`, avoiding cross-caller
enumeration. Avoid recreating a list endpoint unless the application has a
well-defined authorization scope. See the
[configuration guide](CONFIGURATION.md#tasks-extension) for the explicit
capability opt-in and callback boundary.

## 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:

```elixir
{: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:

```elixir
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:

```elixir
{: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.

### Authorization callback issuer validation

ExMCP registers every library-started authorization-code transaction before
returning its authorization URL. It generates random 256-bit `state` and PKCE
values, stores only SHA-256 digests of state and authorization codes, and
atomically moves the transaction through pending, code-ready, and redeemed
states. Exactly one concurrent callback and one code redemption can succeed.

`ExMCP.Authorization.validate_authorization_response/2` verifies state and,
when the callback includes the RFC 9207 `iss` parameter, requires exact
equality with the issuer recorded when the flow started. Issuer identifiers are
not normalized: a trailing slash or path difference is a mismatch. A present
`iss` is rejected when the transaction did not record an issuer. Redemption is
also bound to the exact redirect URI recorded at flow start.

The built-in full OAuth flow performs this validation automatically. It also
bounds callback request/query sizes, rejects duplicate callback parameters,
and does not log the authorization URL, callback URL, code, state, PKCE
verifier, cookies, client secrets, or tokens. OAuth error logging and telemetry
redact credential-shaped fields, authorization header values, and URL queries
and fragments.

Transactions remain redeemed before the token request is sent. This fail-closed
ordering prevents code replay: after a timeout or lost response, start a new
authorization flow instead of retrying the same code. The default transaction
store is bounded, expires records, and is node-local. Route a loopback callback
to the originating node; distributed browser callbacks require a strongly
consistent application-owned flow.

Caller-constructed transaction maps without the opaque `transaction_id` retain
their 1.x validation behavior for source compatibility, but do not gain atomic
replay protection. Always carry the transaction returned by
`start_authorization_flow/1` through callback validation and token exchange.

### MCP routing-header confidentiality

Treat every `Mcp-Param-*` value as sensitive. These values mirror selected
tool arguments and may contain tenant, region, account, or routing data.
ExMCP does not include them in its HTTP debug logs or telemetry, but reverse
proxies, load balancers, APM agents, and access-log middleware may record
request headers independently. Configure those systems to redact
`Mcp-Param-*` with the same policy used for `Authorization` and cookies.

Modern HTTP servers compare protocol, method, name, and annotated parameter
headers with the JSON-RPC body before dispatch. Missing, duplicate, malformed,
oversized, or mismatched recognized headers return HTTP 400 with JSON-RPC
error `-32020`; the rejected header value is not copied into the response or
logs. Unsupported protocol-version errors may identify the requested version,
but `Mcp-Param-*` values are never echoed.

## 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](#outbound-requests-trusted-origins-and-consent).

**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.
