Skip to content
Go back

The Header Your Reverse Proxy Keeps Dropping

By SumGuy 5 min read
The Header Your Reverse Proxy Keeps Dropping

The Mystery: Client IP is Always 127.0.0.1

You log into your backend application (Nextcloud, WikiJS, whatever). Check the access logs and every single request shows the client IP as 127.0.0.1 or localhost. But you know traffic’s coming from different places.

The reverse proxy is eating the original client IP.

This breaks geolocation, rate limiting, security logging, and anything that cares about where traffic came from. You need to fix it properly instead of just ignoring the problem.

Why This Happens

When a reverse proxy (Nginx, HAProxy, Caddy) forwards a request to your backend, it creates a new TCP connection. As far as the backend sees it:

The proxy needs to tell the backend where the request actually came from. It does this via an HTTP header: X-Forwarded-For.

The Correct Headers (All Three)

Reverse proxies should pass three headers to the backend:

X-Forwarded-For: client-ip
X-Forwarded-Proto: http or https (which protocol was original)
X-Forwarded-Host: original hostname

Most proxies don’t do this by default. You have to configure it.

Fix It in Nginx

This is the most common setup:

upstream backend {
server 127.0.0.1:3000;
}
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://backend;
# REQUIRED: Forward the original client IP
proxy_set_header X-Forwarded-For $remote_addr;
# REQUIRED: Forward the original protocol
proxy_set_header X-Forwarded-Proto $scheme;
# REQUIRED: Forward the original hostname
proxy_set_header X-Forwarded-Host $server_name;
# Nice to have: Forward the port
proxy_set_header X-Forwarded-Port $server_port;
# Remove the connection header (allows keepalive to the backend)
proxy_set_header Connection "";
proxy_http_version 1.1;
}
}

Every one of those proxy_set_header lines is doing something crucial. Don’t skip any of them.

Check If Nginx Is Actually Sending Them

Terminal window
# Connect directly to your backend (bypassing the proxy):
curl -v http://localhost:3000/
# You'll see: X-Forwarded-For header is missing (because you're local)
# Now through the proxy:
curl -v http://example.com/
# Check the backend logs to see what IP it received

Or use tcpdump to spy on the headers:

Terminal window
sudo tcpdump -i lo port 3000 -A | grep -i "x-forwarded"

Backend Configuration: How to Use These Headers

Node.js / Express

const express = require('express');
const app = express();
// Trust the proxy:
app.set('trust proxy', 1);
app.use((req, res, next) => {
console.log("Client IP:", req.ip); // Now gives original IP
console.log("User Agent:", req.get('user-agent'));
next();
});
app.listen(3000);

That trust proxy line tells Express to read the X-Forwarded-For header instead of using the connection IP.

Django

# settings.py:
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')

Now Django respects the X-Forwarded-Proto header.

Next.js / Vercel

import { headers } from 'next/headers';
export default function MyPage() {
const headersList = headers();
const clientIP = headersList.get('x-forwarded-for');
return <div>Your IP: {clientIP}</div>;
}

Python Flask

from flask import Flask, request
from werkzeug.middleware.proxy_fix import ProxyFix
app = Flask(__name__)
# Trust one level of proxy (Nginx is one level)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
@app.route('/')
def hello():
client_ip = request.remote_addr
return f'Your IP: {client_ip}'

The Dangerous Part: Trusting the Header Blindly

Here’s the problem: X-Forwarded-For is just an HTTP header. It’s not validated. If you have zero security:

  1. Remove the reverse proxy
  2. A malicious client directly connects to port 3000
  3. They can set their own X-Forwarded-For: 1.2.3.4
  4. Your app thinks they’re coming from 1.2.3.4

Solution: Only trust the header if it comes from a known proxy:

# In Nginx, set a different header that the backend trusts:
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
# Add your proxy IP to the header only if it's trusted:
# (Nginx does this automatically in newer versions)

In your backend, trust X-Real-IP only if it came through Nginx:

# Django:
def get_client_ip(request):
x_real_ip = request.META.get('HTTP_X_REAL_IP')
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
# Only trust X_REAL_IP if connection is from localhost (Nginx):
if request.META.get('REMOTE_ADDR') == '127.0.0.1':
return x_real_ip or x_forwarded_for.split(',')[0]
return request.META.get('REMOTE_ADDR')

Better: If your proxy is on the same machine, just trust all headers (they can’t be forged from outside).

Fix It in Caddy

Caddy gets this right by default:

:80 {
reverse_proxy localhost:3000 {
header_upstream X-Forwarded-For {remote_host}
header_upstream X-Forwarded-Proto {http.request.proto}
}
}

Actually, Caddy adds these automatically. You don’t even need to configure them.

Fix It in HAProxy

backend backend_servers
server app1 127.0.0.1:3000
# Forward the real IP:
http-reuse safe
option forwardfor
http-send-name-header X-Forwarded-Host

Test Everything End-to-End

Terminal window
# 1. Make a request through the proxy
curl -H "User-Agent: Testing" http://example.com/api/info
# 2. Check backend logs:
tail -f /var/log/app/access.log
# You should see:
# Client IP: [your actual IP, not 127.0.0.1]
# Headers: X-Forwarded-For: [your IP], X-Forwarded-Proto: http
# 3. If you still see 127.0.0.1:
# - Check Nginx config for the proxy_set_header lines
# - Verify the backend is reading the header correctly
# - Check backend logs (not access logs) for what it received

Real Example: Nextcloud Behind Nginx

Nextcloud needs these headers to work correctly (geolocation, rate limiting):

upstream nextcloud {
server 127.0.0.1:8080;
}
server {
listen 80;
server_name cloud.example.com;
location / {
proxy_pass http://nextcloud;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $server_name;
proxy_set_header X-Forwarded-Port $server_port;
# Nextcloud also needs Host:
proxy_set_header Host $host;
# And this one for WebDAV:
proxy_set_header Connection "upgrade";
}
}

Then in Nextcloud’s config:

'trusted_proxies' => ['127.0.0.1'], // Only trust from local Nginx
'overwriteprotocol' => 'https',

Done. Now Nextcloud logs show real client IPs, geolocation works, and rate limiting applies per-actual-client, not per-proxy.

The Bottom Line

This is one of those things that looks optional but breaks geolocation, rate limiting, and security logging. It takes five minutes to configure correctly. Don’t skip it.

Reverse proxy headers aren’t magic. They’re just HTTP headers that say “this request originally came from here”. Configure your proxy to send them, configure your backend to read them, and suddenly your app knows who’s actually connecting.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it may appear here.


Previous Post
Chaos Engineering: Break Things on Purpose Before They Break Themselves
Next Post
Kernel Live Patching: Security Updates Without the 3am Reboot

Related Posts