Why X-Forwarded-For is intentionally ignored
mcpgw’s IP-based rate-limit identity is derived from RemoteAddr (the TCP peer’s host) only. The X-Forwarded-For and Forwarded headers are not consulted, by design, in v1 and v1.1.
This page explains why, and what to do about it when you front the gateway with a reverse proxy.
The threat model
X-Forwarded-For is a header. Headers come from the client. Any client can send any header value.
The convention is that a trusted proxy strips the incoming XFF and replaces it with the real client IP. But mcpgw does not know which proxies are trusted unless you tell it. And historically, “the proxy is trusted” turned into a load-bearing assumption that was wrong in surprising places — VPN exits, internal forwarders, container ingress, the cloud provider’s own meta-proxies.
If mcpgw trusted X-Forwarded-For, an attacker who reached the gateway directly (bypassing the LB) could spoof their identity by sending X-Forwarded-For: 8.8.8.8 and either:
- Evade per-IP throttles by rotating fake IPs
- Frame a known-good IP as the source of abuse (e.g. to get it banned)
The first attack is more common; the second is more interesting. Both are defeated by ignoring the header entirely.
What the safe default is
RemoteAddr is the IP that actually opened the TCP connection. It cannot be forged at the application layer. Whatever sat at the other end of the kernel’s socket is what RemoteAddr reports.
The cost: if your gateway sits behind an LB, every request appears to come from the LB’s IP, and all clients share one rate-limit bucket. This is annoying. It is also safe in the absence of operator action, which is what we wanted.
What you do about it
There are two supported ways to recover real client identity for rate-limit purposes. Both move the responsibility to the operator (you), where it belongs.
Option 1 — Terminate TLS at mcpgw
Skip the LB entirely. Run mcpgw as the TLS edge. RemoteAddr is the real client.
listen: 0.0.0.0:7332
tls:
cert_file: /etc/mcpgw/tls/fullchain.pem
key_file: /etc/mcpgw/tls/privkey.pem
Renew certs out-of-band (cert-manager, certbot, ACM-side renewals + bind-mount swap). mcpgw re-reads cert/key on SIGHUP.
This is the recommended v1 topology because it eliminates an entire class of misconfiguration.
Option 2 — PROXY protocol
Put HAProxy or NGINX in PROXY-protocol mode in front. They prepend a small TCP-level header that mcpgw consumes; RemoteAddr is then populated from the PROXY header rather than the connecting peer.
PROXY protocol differs from X-Forwarded-For in two important ways:
- It’s a TCP-level frame, not an HTTP header. Clients cannot send it on a normal HTTP connection — they would have to know to wrap their request in a PROXY frame, and the receiving server has to be expecting one.
- mcpgw enforces a
trusted_cidrsallowlist. PROXY frames are accepted only from source IPs in the list. A direct attacker reaching:7332from outside the LB’s network sends a normal HTTP request and gets normalRemoteAddr— they cannot inject a PROXY frame because their IP isn’t trusted.
proxy_protocol:
enabled: true
trusted_cidrs:
- 10.0.0.0/24
Native PROXY-protocol parsing lands in v1.1. In v1, the proxy_protocol block is parsed but rejected at startup with a clear error.
What about trusted_proxies?
A trusted_proxies list — “trust X-Forwarded-For if the immediate peer is in this allowlist” — is the third common option. It’s not in v1 because:
- It is fragile to misconfigure. The list has to be the immediate peer, not “any of our edge LBs”; multi-hop networks accidentally trust the wrong hop.
- It still fails open against an attacker who reaches the gateway directly through a misconfigured firewall — the
trusted_proxiesallowlist doesn’t help if the attacker’s source IP happens to be in it (e.g. lateral movement from inside a trusted CIDR). - The deployment topologies it supports (Cloudflare-fronted, multi-region behind cloud LBs) are also exactly the topologies where mcpgw’s per-IP throttle is the least important defense, because the edge already does its own throttling.
v1.2 adds trusted_proxies for operators who explicitly want it, with documentation that warns about the failure modes above. For now, choose Topology 1 or Topology 2 depending on whether you can move TLS termination.
What this does not affect
Two important clarifications:
Per-session rate-limit identity is unrelated. When a policy.rules[] rule has action: rate_limit, the bucket key is (rule_id, Mcp-Session-Id). The session id comes from a header your client controls; it has nothing to do with RemoteAddr. Rate-limit identity at the policy layer scales with sessions, not IPs.
Gateway input rate-limit identity uses RemoteAddr. The top-level input_rate_limit runs before body read, auth, parse, policy, and routing. Its bucket key is the TCP peer address from RemoteAddr, and it intentionally ignores X-Forwarded-For. This protects mcpgw’s own pre-parse work from request floods; it is not a per-tool quota.
The client_ip field in the audit log is RemoteAddr too. That field is for traceability of the connection, not for client identification. If your audit log says client_ip: <LB-IP>, that is correct — the LB is the connection peer. Real client identity, when present, lives in session_id.
Why this isn’t just a config flag
A common reaction is: “Just give me a trust_xff: true flag and let me opt in.” The reason mcpgw doesn’t is that the flag’s failure mode is invisible. With the flag off, you see “all rate-limit buckets share one IP” and immediately fix it. With the flag on, you see no error at all — until somebody discovers they can spoof identity, and you discover it from a security incident.
The configurations that make XFF safe — strict edge stripping, strict trusted-proxies enforcement, network paths that prevent direct gateway access — are configurations that are easier to validate at the network layer than to validate from a flag in a YAML file. If you can validate them at the network layer, do that, and use Topology 2. If you can’t, use Topology 1.