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
- Bind the HTTP listener to
127.0.0.1:8080and put any reverse proxy in front of it (Caddy 2.11.4 with a plainreverse_proxy 127.0.0.1:8080in my case — it addsX-Forwarded-Forautomatically). - Enable Network → HTTP → “Obtain remote IP from Forwarded header” (
use-x-forwarded). - Log in through the proxy from an external client (webadmin or JMAP basic auth).
- Observe the
auth.successevent:remoteIp = 127.0.0.1instead of the client’s IP. - To confirm the forwarded header itself is honored: pick an IP currently listed in
security.ip-blockedevents and runcurl -H "X-Forwarded-For: <banned-ip>" http://127.0.0.1:8080/jmap/sessionon the host → HTTP 403, and the resultingsecurity.ip-blockedevent 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 appendsX-Forwarded-For; zerohttp.x-forwarded-missingwarnings in day-to-day logs confirm the header is always present). - Minor side observation: the
security.ip-blockedevent above printslistenerIdandremoteIptwice (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