A reverse proxy accepts a client connection and makes a separate request to an upstream application. The application’s socket peer is therefore the proxy, while information about an earlier client can arrive through request headers. Those headers are useful only when the deployment defines who may set them and how they are validated. Proxying a request does not automatically make every forwarded identity or scheme claim trustworthy.

When it breaks

A client can supply X-Forwarded-For before reaching the proxy. Ordinary forwarding may preserve that value, and $proxy_add_x_forwarded_for appends the current peer address rather than removing an untrusted prefix. An application that blindly trusts the first address can therefore attribute the request to a client-selected value. The correct interpretation depends on the actual trusted proxy chain.

Host handling is another independent decision. nginx’s default upstream Host is based on the proxy destination, not automatically the public Host the client used. An application building absolute redirects or selecting virtual hosts can consequently behave differently behind the proxy. Forward only the intended host context and apply the application’s accepted-host policy; copying a header does not validate the name it contains.

The proxy also does not guarantee that backend details remain undisclosed. Response headers, redirects, bodies, diagnostics, or other public configuration can reveal information about the origin. Restricting direct reachability and controlling selected response fields are useful measures, but they are different from claiming that one proxy_pass directive hides all upstream identity or provides every required transport protection.

Check it quickly

Run the script with host Python, Docker, and the pinned nginx and Python images. The recorded environment used nginx 1.28.0 and Python 3.11.16 on Linux arm64 with Docker 29.3.1. It creates a private Docker network and a real echo backend, publishes only the proxy on host loopback, validates configuration, and cleans up its own resources. No production endpoint or credential is involved:

from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import http.client,json,socket,subprocess,time,uuid

NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609'
PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True):
    result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120)
    if check:assert result.returncode==0,result.stderr
    return result
def wait(predicate):
    deadline=time.monotonic()+15
    while time.monotonic()<deadline:
        try:
            if predicate():return
        except OSError:pass
        time.sleep(.1)
    raise AssertionError('fixture did not become ready')

@contextmanager
def lab(configuration,files=None,backend=None):
    name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend']
    try:
        with TemporaryDirectory(prefix=name+'-') as directory:
            root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration)
            for path,content in (files or {}).items():
                target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True);target.write_text(content)
            docker('network','create',name)
            if backend is not None:
                (root/'backend.py').write_text(backend)
                (root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n')
                docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root))
                docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1])
                wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0)
            (root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n')
            docker('build','--network','none','-t',tags[0],str(root))
            docker('run','--rm','--network',name,tags[0],'nginx','-t')
            docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',tags[0])
            details=json.loads(docker('inspect',containers[0]).stdout)[0]
            binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort'])
            def ready():
                connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True
            wait(ready)
            def request(path,method='GET',headers=None,body=None):
                connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5)
                try:
                    connection.request(method,path,body=body,headers=headers or {})
                    response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read()
                finally:connection.close()
            yield containers[0],request
    finally:
        docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False)

def config(locations,extra=''):
    return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n'

backend=r'''from http.server import BaseHTTPRequestHandler,HTTPServer
import json
class Handler(BaseHTTPRequestHandler):
 def do_GET(self):
  body=self.rfile.read(int(self.headers.get('Content-Length','0')))
  document={'path':self.path,'method':self.command,'body':body.decode(),'headers':{key:self.headers.get(key) for key in ['Host','X-Forwarded-For','X-Real-IP','X-Forwarded-Proto','X-Forwarded-Host']}}
  data=json.dumps(document).encode()
  self.send_response(200);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(data)));self.send_header('X-Backend-Info','fixture-node');self.end_headers();self.wfile.write(data)
 do_POST=do_GET
 def log_message(self,*args):pass
HTTPServer(('0.0.0.0',8081),Handler).serve_forever()
'''

locations=r'''
location /default { proxy_pass http://backend:8081; }
location /append { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://backend:8081; }
location /edge {
 proxy_set_header Host $host;
 proxy_set_header X-Forwarded-For $remote_addr;
 proxy_set_header X-Real-IP $remote_addr;
 proxy_set_header X-Forwarded-Proto $scheme;
 proxy_set_header X-Forwarded-Host $host;
 proxy_hide_header X-Backend-Info;
 proxy_pass http://backend:8081;
}
'''
with lab(config(locations,'server_tokens off;'),backend=backend) as (container,request):
    forged={'Host':'public.example','X-Forwarded-For':'198.51.100.77','X-Forwarded-Proto':'forged-scheme','X-Real-IP':'198.51.100.88'}
    status,headers,body=request('/default',headers=forged);plain=json.loads(body)
    assert status==200 and plain['headers']['Host']=='backend:8081'
    assert plain['headers']['X-Forwarded-For']=='198.51.100.77' and plain['headers']['X-Forwarded-Proto']=='forged-scheme'
    assert headers['x-backend-info']=='fixture-node'
    status,headers,body=request('/append',headers=forged);appended=json.loads(body)['headers']['X-Forwarded-For'].split(', ')
    assert status==200 and len(appended)==2 and appended[0]=='198.51.100.77'
    status,headers,body=request('/edge',headers=forged);edge=json.loads(body)['headers']
    assert status==200 and edge['Host']==edge['X-Forwarded-Host']=='public.example'
    assert edge['X-Forwarded-For']==edge['X-Real-IP']==appended[-1]
    assert edge['X-Forwarded-For'] not in ['198.51.100.77','198.51.100.88'] and edge['X-Forwarded-Proto']=='http'
    assert 'x-backend-info' not in headers and headers['server']=='nginx'
    print('ordinary forwarding preserves forged forwarding headers and defaults Host to the upstream authority')
    print('append retains the untrusted prefix; the direct-client edge configuration replaces it with the actual socket peer and scheme')
    print('explicit response-header suppression removes one backend field, while server_tokens off still exposes the nginx product name')

Every test request supplies public.example as Host and deliberately forged forwarding values using documentation-only IP addresses. The default route sends Host as backend:8081 and preserves the forged X-Forwarded-For and X-Forwarded-Proto. This directly demonstrates that normal proxy forwarding is not an identity-sanitization step and does not preserve the public host automatically.

The append route uses $proxy_add_x_forwarded_for. Its backend receives two comma-separated addresses: the client-supplied address first, followed by nginx’s observed socket peer. The original untrusted prefix remains. Appending useful connection context does not make the entire resulting list equally authoritative, and trusting its leftmost entry would defeat the intended boundary in this direct-client setup.

The edge route replaces X-Forwarded-For and X-Real-IP with $remote_addr, sets the forwarded scheme from $scheme, and forwards the chosen host context explicitly. The backend receives the actual observed peer value rather than either forged address, and the scheme is http in this deliberately plain-HTTP fixture. The test compares the values across requests without assuming a particular Docker bridge address.

That socket peer can reflect Docker’s networking path; it is not a claim to reconstruct an arbitrary internet user’s address through every NAT or earlier proxy. If the edge itself sits behind a trusted load balancer, the deployment needs a deliberate policy for accepting that hop’s information. The example tests a direct-client trust boundary, not every possible proxy chain.

The backend also emits X-Backend-Info: fixture-node. The default route exposes it, while the edge route’s explicit proxy_hide_header removes it. With server_tokens disabled, the response still says Server: nginx. This separates suppression of one backend field from hiding the proxy product name or proving that no other response content discloses implementation details.

Connect network access and header interpretation

Limit which callers can reach the backend directly when it relies on proxy-established context. Configure trusted proxy addresses narrowly if using the real IP module, and ensure the resulting address interpretation matches the application’s own trust settings. A broad trust range can allow an unintended caller to supply apparently authoritative forwarding information.

Decide where TLS terminates and whether the upstream connection also needs encryption and certificate verification. The scheme nginx observes on one hop does not automatically describe an earlier hop. Test redirects, secure-cookie behavior, and generated URLs through the actual deployment path when they depend on forwarded scheme or host.

Finally, inspect the request received by the application and the response received by the client. Header configuration, network isolation, and disclosure controls each answer a different question. Evidence from both sides makes their combined behavior reviewable without assuming that the proxy silently supplies every missing guarantee.

Sources