Issue Description
With useXForwarded enabled behind a reverse proxy, Stalwart’s auto-ban and
rate-limiting correctly resolve the real client IP from X-Forwarded-For, but
auth.success / auth.failed events log the TCP peer address (the proxy) instead.
The client IP is demonstrably available to the process at the moment the event
fires — it is simply not the value recorded on the event.
Three observations confirm this is not a proxy misconfiguration:
-
Auto-ban resolves the correct IP. Repeated failed logins from an external
client caused Stalwart to ban that client’s real public IP, not the proxy
address. The ban subsystem is reading X-Forwarded-For correctly. -
useXForwarded is demonstrably active. Stalwart previously emitted
http.x-forwarded-missingwarnings, which are only produced when the setting
is enabled. After correcting the proxy chain, that warning count is 0 across
5000 log lines — the header is present and parsed on every request. -
PROXY protocol works on the mail listeners. Ports 25/465/587/993 receive
PROXY v2 from HAProxy and log real public client IPs correctly. Only the
HTTP-layer auth events are affected.
So within a single request, the same process resolves the client IP correctly for
banning while recording the proxy’s IP on the auth event.
This appears to be the same root cause as discussion #2806.
Expected Behavior
auth.success and auth.failed should report the client address resolved from
X-Forwarded-For — the same address the anti-brute-force subsystem already uses.
Per the documentation, useXForwarded causes Stalwart to determine the client’s
IP from the Forwarded / X-Forwarded-For header:
Actual Behavior
Auth events report the reverse proxy’s address instead of the client’s.
The deviation: for the same request, the ban subsystem records the real client
IP while the auth event records the proxy IP. These two values disagree, and only
the ban subsystem matches the documented behaviour of useXForwarded.
Reproduction Steps
- Place Stalwart behind a reverse proxy that terminates TLS and sets
X-Forwarded-For (Traefik, nginx, Caddy). - Route the proxy to Stalwart’s HTTP listener over plain HTTP (:8080).
- Enable
useXForwarded. - Authenticate to the web admin or JMAP from an external client.
- Observe the auth.success event — remoteIp is the proxy’s address.
- For contrast, fail authentication several times from the same client and
inspect the blocked-IP list: the correct client IP is banned.
Relevant Log Output
Authentication successful (auth.success) listenerId = “http”, localPort = 8080,
remoteIp = 10.0.1.211, remotePort = 55420, accountName = “”,
accountId =
10.0.1.211 is the reverse proxy’s container address on the Docker overlay.
The real client was an external host reaching Stalwart through that proxy.
Count of http.x-forwarded-missing across the same 5000 log lines: 0
Stalwart Version
v0.16.x
Installation Method
Docker
Database Backend
RocksDB
Blob Storage
RocksDB
Search Engine
Internal
Directory Backend
LDAP
Additional Context
Reverse proxy chain:
client → HAProxy (host-mode, PROXY v2) → Traefik v3.7.10 → Stalwart :8080 (HTTP)
External clients additionally traverse a Pangolin/Newt tunnel that sets
X-Forwarded-For before Traefik.
Mail path (working correctly):
client → HAProxy (mode tcp, send-proxy-v2) → Stalwart 25/465/587/993
These log real public client IPs, confirming PROXY protocol handling is fine.
Traefik is configured with forwardedHeaders.trustedIPs so the upstream
X-Forwarded-For is preserved rather than overwritten.
Related: discussion #2806 describes the same symptom and suggests the logging
span is created at TCP accept, before the HTTP handler reads X-Forwarded-For.
I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.
on
I have searched this support forum (open and closed topics) and confirm this is not a duplicate.
on
I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.
on