auth.success / auth.failed log the TCP peer IP instead of the X-Forwarded-For client

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:

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

  2. useXForwarded is demonstrably active. Stalwart previously emitted
    http.x-forwarded-missing warnings, 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.

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

  1. Place Stalwart behind a reverse proxy that terminates TLS and sets
    X-Forwarded-For (Traefik, nginx, Caddy).
  2. Route the proxy to Stalwart’s HTTP listener over plain HTTP (:8080).
  3. Enable useXForwarded.
  4. Authenticate to the web admin or JMAP from an external client.
  5. Observe the auth.success event — remoteIp is the proxy’s address.
  6. 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

The proxy IP address is displayed at the beginning of the span but after that the real ip address is reported. This causes two IP addresses to be displayed in the logs.

This behaviour can’t be changed due to how TCP connections are layered. Switch to the proxy protocol if this is an issue.