`auth.success` event logs the proxy socket IP instead of the forwarded client IP when "use X-Forwarded" is enabled.

Issue Description

Stalwart runs behind a Caddy reverse proxy on the same host (HTTP listener bound to 127.0.0.1:8080, mail ports exposed directly). Network → HTTP → “Obtain remote IP from Forwarded header” is enabled and demonstrably active: requests without X-Forwarded-For trigger the http.x-forwarded-missing warning, and the security layer correctly resolves and blocks forwarded client IPs (details below).

However, auth.success events always report remoteIp = 127.0.0.1 (the proxy’s socket address) for logins arriving through the proxy. The forwarded client IP is not shown anywhere in the event. This makes sign-in auditing impossible behind a reverse proxy and misleads admins into believing the forwarded-header setting is broken — that’s how I started this investigation, and only spoof-testing the blocking layer proved the setting actually works.

I verified with ss -tnp that the connection owning the logged remotePort belonged to the Caddy process, i.e. the login verifiably came from an external client through the proxy — not from a local process.

Expected Behavior

When “Obtain remote IP from Forwarded header” is enabled, auth.success (and related auth/session events) should report the forwarded client IP — or include both addresses, as the security.ip-blocked event effectively does.

Actual Behavior

auth.success reports only the proxy socket address (remoteIp = 127.0.0.1), while the security layer (e.g. security.ip-blocked) correctly uses the forwarded client IP. The deviation is specifically in what the auth event displays, not in how the IP is used for security decisions.

Reproduction Steps

  1. Bind the HTTP listener to 127.0.0.1:8080 and put any reverse proxy in front of it (Caddy 2.11.4 with a plain reverse_proxy 127.0.0.1:8080 in my case — it adds X-Forwarded-For automatically).
  2. Enable Network → HTTP → “Obtain remote IP from Forwarded header” (use-x-forwarded).
  3. Log in through the proxy from an external client (webadmin or JMAP basic auth).
  4. Observe the auth.success event: remoteIp = 127.0.0.1 instead of the client’s IP.
  5. To confirm the forwarded header itself is honored: pick an IP currently listed in security.ip-blocked events and run curl -H "X-Forwarded-For: <banned-ip>" http://127.0.0.1:8080/jmap/session on the host → HTTP 403, and the resulting security.ip-blocked event carries the forwarded IP.

Relevant Log Output

# Login through the proxy (remotePort 59336 verified via ss -tnp to be the Caddy process):
2026-07-17T20:39:48Z INFO Authentication successful (auth.success) listenerId = "http", localPort = 8080, remoteIp = 127.0.0.1, remotePort = 59336, accountName = "admin@…", accountId = 1

# Request without X-Forwarded-For — proves the setting is active:
2026-07-17T20:46:03Z WARN X-Forwarded-For header is missing (http.x-forwarded-missing) listenerId = "http", localPort = 8080, remoteIp = 127.0.0.1, remotePort = 49456

# Request with X-Forwarded-For set to a banned IP — proves the security layer uses the forwarded IP:
2026-07-17T20:46:28Z INFO Blocked IP address (security.ip-blocked) listenerId = "http", localPort = 8080, remoteIp = 127.0.0.1, remotePort = 47008, listenerId = "http", remoteIp = 158.94.210.216

Stalwart Version

v0.16.x

Installation Method

Binary (Linux)

Database Backend

PostgreSQL

Blob Storage

S3-compatible

Search Engine

PostgreSQL

Directory Backend

Internal

Additional Context

  • Reverse proxy: Caddy 2.11.4, same host, plain reverse_proxy 127.0.0.1:8080 (Caddy always appends X-Forwarded-For; zero http.x-forwarded-missing warnings in day-to-day logs confirm the header is always present).
  • Minor side observation: the security.ip-blocked event above prints listenerId and remoteIp twice (socket context + forwarded context concatenated). Listing both IPs is useful, but as duplicated keys it looks accidental and is awkward for structured log parsing — a distinct key (e.g. forwardedIp / clientIp) would be clearer.

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

It is normal behavior when you use a reverse proxy. Anyway, autobanning works correctly.

This is a won’t fix, sorry. The span with the IP address is created at the TCP layer before is routed to the HTTP processor. The recommended way to run Stalwart behind a proxy is enabling the proxy protocol.

This is not a solution, but you can set logging to debug or use a script to monitor a specific account.