A protected MCP server publishes two origin values a client trusts implicitly:
- the RFC 9728
resourceidentifier (the server's own canonical URL), served by the metadata endpoint and echoed in theWWW-Authenticateresource_metadatachallenge URL, and authorization_servers— where the client goes to get a token.
By default attesto_mcp derives both from the live request connection
(conn.scheme + conn.host + conn.port). For a single host that is correct
and needs no configuration.
Why deriving from the request is wrong behind a proxy
When TLS is terminated upstream and the app sees proxied requests:
- Wrong scheme/host.
conn.schemeis usuallyhttpandconn.hostis whateverX-Forwarded-*rewriting produced, so the advertisedresourcecan come out ashttp://…or an internal hostname unless every conn is rewritten perfectly. - Spoofing. If the app trusts a proxy-rewritten
Host/X-Forwarded-Host, an attacker sendingX-Forwarded-Host: evil.examplecan make the metadata endpoint advertise an attacker-controlledresourceor — worse — an attacker-controlledauthorization_servers, steering the client to request tokens from an attacker AS.
The metadata document is the one place a client learns where to authorize, so this is a redirect-to-attacker-AS vector. A FAPI-grade deployment should pin these values instead of trusting the request.
The recipe
Two independent knobs, because RFC 9728 keeps the resource server and its authorization server distinct (they are usually different hosts under FAPI):
| Value | Knob | Source |
|---|---|---|
resource + challenge URL | :base_url / :origin | the resource server's own canonical origin |
authorization_servers | :config issuer, :issuer, or explicit :authorization_servers | the trusted authorization server |
:base_url/:origin accept a String.t(), a (Plug.Conn.t() -> String.t())
callback, or a {Mod, :fun} / {Mod, :fun, args} tuple (the conn is prepended
to args), resolved at request time. A blank or non-binary result is treated as
"not configured" and falls back to the request origin.
The issuer (authorization_servers) is advertised verbatim — issuer
identifiers are compared by exact string match, so a trailing slash is preserved
(unlike the resource origin, where it is trimmed before joining the path).
Router (metadata endpoint)
defmodule MyAppWeb.Router do
use Phoenix.Router
use AttestoMCP.Router
scope "/" do
pipe_through :api
attesto_mcp_protected_resource_metadata "/mcp",
scopes: ["mcp:tools:call"],
# pin the resource server origin (resource + well-known URL)
base_url: "https://mcp.example.com",
# pin the authorization server the client should use
config: &MyApp.Attesto.config/0
end
endRoute private is compiled, so pass a string or a &Mod.fun/1 capture
for :base_url/:origin — not an anonymous fn. The same applies to :config
(a &Mod.config/0 capture works).
Endpoint (the WWW-Authenticate challenge)
Pin the same :base_url/:origin on AttestoMCP.Plug.ProtectResource so the
challenge URL matches the served resource:
plug AttestoMCP.Plug.ProtectResource,
config: &MyApp.Attesto.config/0,
resource: "/mcp",
scopes: [AttestoMCP.Scopes.tools_call()],
base_url: "https://mcp.example.com"With the origin pinned you do not need a bespoke canonical-host guard plug in
front of the metadata routes: a spoofed Host/X-Forwarded-Host can no longer
change the advertised resource, challenge URL, or authorization_servers.
Using a callback
When the canonical origin is computed (multi-tenant, per-deployment config), pass a capture instead of a literal:
# lib/my_app/attesto.ex
def mcp_origin(_conn), do: MyApp.Config.canonical_url()
# router / plug
base_url: &MyApp.Attesto.mcp_origin/1What stays automatic
- Omit every knob and you get the previous behavior: both origins derived from the request connection — correct for a simple single-host setup.
authorization_serversonly changes from the resource origin when you supply a:config/:issuer(or an explicit:authorization_servers), so existing single-host deployments are unaffected.