Client IP

Why does uvicorn or gunicorn show 127.0.0.1 or 172.17.0.1 as the client IP behind nginx?

Uvicorn trusts X-Forwarded-For only from 127.0.0.1 and ::1; Gunicorn's WSGI workers never read it. The fix per server, Docker and UNIX socket cases, and why * is risky.

· 16 min read · How we verify this

Key points

  • Uvicorn rewrites the client address from X-Forwarded-For only when the connection comes from a peer in --forwarded-allow-ips. The default is 127.0.0.1 and ::1. nginx in another container connects from a 172.x address, so uvicorn ignores the header and reports nginx.
  • Fix it with --forwarded-allow-ips (or FORWARDED_ALLOW_IPS) set to nginx's address or its Docker network, for example 172.18.0.0/16. The same list decides whether uvicorn believes X-Forwarded-Proto.
  • Gunicorn's own workers (sync, gthread, gevent) never set REMOTE_ADDR from X-Forwarded-For, whatever --forwarded-allow-ips says. That setting only gates the scheme headers. Use Werkzeug's ProxyFix or the framework instead.
  • Gunicorn with uvicorn_worker.UvicornWorker does pass gunicorn's --forwarded-allow-ips to uvicorn. The old bug report, uvicorn issue #589, was closed in 2020, and uvicorn-worker 0.4.0 passes the value.
  • * makes uvicorn take the leftmost X-Forwarded-For entry, which the visitor can write. It is safe only if nobody can reach the app except through nginx and nginx overwrites the header with $remote_addr. Over a UNIX socket, * is the only value that works.

Uvicorn shows nginx's address because it only believes X-Forwarded-For from peers it trusts. By default it trusts only 127.0.0.1 and ::1. nginx in another container connects from a Docker address such as 172.18.0.3. On the host, through a published port, it shows up as the bridge gateway, 172.17.0.1 on the default bridge. Neither is on the list, so uvicorn ignores the header and reports the peer. The fix is one flag: --forwarded-allow-ips set to nginx's address or network.

Gunicorn is a different story, and that is where most of the confusion comes from. Gunicorn has a setting with the same name and the same default. But its own workers never use X-Forwarded-For to set the client address at all. When gunicorn shows 127.0.0.1, no gunicorn flag will change it.

ServerSets the client from X-Forwarded-For?Trust settingDefaultOver a UNIX socketX-Forwarded-Proto
Uvicorn 0.54.0Yes, when the peer is trusted--forwarded-allow-ips or FORWARDED_ALLOW_IPS127.0.0.1,::1Peer is None, so headers are ignored unless *Same list; only http, https, ws, wss
Gunicorn 26.2.0 with uvicorn_worker.UvicornWorker 0.4.0Yes, by uvicorn's codeGunicorn's --forwarded-allow-ips, passed to uvicorn127.0.0.1,::1Same as uvicornSame as uvicorn
Gunicorn 26.2.0 sync, gthread, geventNo. REMOTE_ADDR is always the socket peer--forwarded-allow-ips gates only the scheme headers and forwarder_headers127.0.0.1,::1REMOTE_ADDR is empty; the socket is trusted for scheme headersX-Forwarded-Proto: https sets wsgi.url_scheme if the peer is allowed
Hypercorn 0.18.0Only with ProxyFixMiddlewaretrusted_hops, a hop count; the peer is never checkedMiddleware offSame as TCPSame hop count; the value is not checked

Each row is explained below, with the code behind it.

What the client IP and forwarded-allow-ips mean here#

The client IP is what your app reads as the visitor's address. In FastAPI or Starlette it is request.client.host. In Flask it is request.remote_addr, and in Django it is request.META["REMOTE_ADDR"]. All of them read one value that the server hands over. In ASGI that value is scope["client"]. In WSGI it is REMOTE_ADDR.

The server fills it from the TCP connection. The peer is the machine on the other end of that connection. Behind nginx, the peer is always nginx, never the visitor.

nginx can pass the visitor's address along in the X-Forwarded-For header. That header is a comma-separated list. Each proxy adds the address it received the request from, on the right. The X-Forwarded-For header explains the format.

Any client can send that header too, with any value in it. So a server must choose whose header to believe. --forwarded-allow-ips is that choice. It is a list of peers that may say who the visitor is. A peer on the list is a trusted proxy. A peer off the list gets its headers ignored, and the app sees the peer's own address.

Uvicorn: the default trusts loopback only#

Uvicorn's settings page says --proxy-headers is on by default, "but is restricted to only trusting connecting IPs in the forwarded-allow-ips configuration". For --forwarded-allow-ips it says: "Defaults to $FORWARDED_ALLOW_IPS if set. Otherwise, 127.0.0.1 and ::1 are trusted. The literal '*' means trust everything."

The code in uvicorn/middleware/proxy_headers.py does three things on each request:

  1. It reads the peer's address from scope["client"].
  2. If the peer is not on the list, it does nothing. The app sees the peer.
  3. If the peer is on the list, it joins all X-Forwarded-For lines into one list. It walks that list from the right and skips every entry that is also trusted. The first entry that is not trusted becomes the client.

Step 3 is the right-to-left walk that configuring trusted proxies describes for nginx, Apache and the rest. The list takes single addresses, networks such as 10.100.0.0/16, and plain strings, called literals. Anything that is not an address or a network is kept as a literal.

Two small points come from the same code. When every entry in the header is trusted, uvicorn takes the leftmost one. That came from issue #1068, where a client running on a proxy host ended up as None. And the new client's port is the one in the header entry. With no port there, it is 0. nginx's $remote_addr carries no port, so behind nginx the port is 0. The deployment docs say the same: "the client's port will be set to 0".

Why the address is 172.x or 127.0.0.1#

Which address you see tells you which case you have.

A 172.x.x.x address from a Docker network, such as 172.18.0.3, is nginx in its own container. Each container has its own address on the network, and nginx connects from it. That is not 127.0.0.1, so uvicorn does not trust it. Uvicorn issue #589 opens with this case. "Because I'm in a container, my WSGI/ASGI server receives requests not from the localhost, but from the docker network."

172.17.0.1, or another address ending in .1, is the network's gateway. You get it when nginx runs on the host. It proxies to the app's published port, such as proxy_pass http://127.0.0.1:8000. Docker's port forwarding then opens the connection into the container from the gateway address. 172.17.0.1 is the gateway of Docker's default bridge network. A Compose network has its own, such as 172.18.0.1. We checked this on Docker 28.1.1 with the default userland-proxy setting. A request from the host to a published port arrived from the gateway address.

127.0.0.1 with uvicorn is the odd one, because uvicorn trusts 127.0.0.1 by default. If nginx and uvicorn share a host and uvicorn still shows 127.0.0.1, uvicorn is not the problem. It means one of these:

  • nginx is not sending X-Forwarded-For on that location. nginx adds no such header unless you write proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;.
  • The location has its own proxy_set_header lines, for WebSockets for example. The nginx docs say these directives are inherited "if and only if there are no proxy_set_header directives defined on the current level". One Upgrade line in a location drops every header line set at the server level.
  • Uvicorn runs with --no-proxy-headers.

127.0.0.1 with gunicorn is expected. The next section explains why.

nginx in Docker and proxy_pass localhost covers which address nginx should connect to in each layout. This page is about the address the app sees once it does.

Gunicorn's WSGI workers never read X-Forwarded-For#

Most guides get this row wrong. Gunicorn's settings page describes forwarded_allow_ips as "Front-end's IP addresses or networks from which allowed to handle set secure headers." The default is '127.0.0.1,::1'. The FORWARDED_ALLOW_IPS variable replaces it if set. The setting is about secure headers, not the client address.

The code agrees. In gunicorn/http/wsgi.py (26.2.0), REMOTE_ADDR is set from the socket peer. The X-Forwarded-For header goes to the app as is, as HTTP_X_FORWARDED_FOR. The trust check in gunicorn/http/message.py returns two things a trusted peer may set: the scheme headers, and forwarder_headers. The forwarder_headers default is SCRIPT_NAME,PATH_INFO. The client address is not one of them. The sync, gthread and gevent workers all use this same code.

So with any of these workers:

  • REMOTE_ADDR is nginx: 127.0.0.1 on one host, or nginx's container address in Docker.
  • --forwarded-allow-ips '*' does not change REMOTE_ADDR. It only lets X-Forwarded-Proto through.

There are two ways to get the real address into a WSGI app:

  • Fix it in the app. For Flask and other Werkzeug apps, wrap the app in ProxyFix. Its defaults in Werkzeug 3.1.9 are x_for=1 and x_proto=1: trust one proxy for each of those headers. With exactly one nginx in front, app.wsgi_app = ProxyFix(app.wsgi_app) is enough. ProxyFix counts hops. It does not check who the peer is, so it trusts anyone who can reach gunicorn's port. Django ships no setting for this; the trusted proxies page explains why and what to do instead.
  • Use the PROXY protocol. Gunicorn's --proxy-protocol (v1, v2 or auto, off by default) sets REMOTE_ADDR from the PROXY header. The peer must be in proxy_allow_ips, which also defaults to 127.0.0.1,::1. nginx's proxy_pass in an http block cannot send this header. nginx's stream module or HAProxy can. The PROXY protocol covers both.

Gunicorn also has its own asgi worker (-k asgi). In its gunicorn/asgi/protocol.py, the scope's client comes from the socket peer, or from the PROXY protocol when that is on. That file has no X-Forwarded-For handling. If you want gunicorn to manage processes and still read the header, use the uvicorn worker.

Gunicorn with the uvicorn worker: the setting is passed through#

Uvicorn issue #589, opened in March 2020, is titled "Uvicorn via gunicorn worker doesn't respect --forwarded-allow-ips". At the time, the worker built uvicorn's config without that value. So uvicorn fell back to its own default. The workaround in the thread was to set FORWARDED_ALLOW_IPS="*" in the environment. Both programs read that variable. The issue was closed in April 2020, and the thread points to pull request #592 as the change.

That issue no longer applies. The worker now lives in its own package, uvicorn-worker. Uvicorn 0.54.0's built-in uvicorn.workers module prints a deprecation warning that points to it. In uvicorn-worker 0.4.0, _workers.py builds the config with "forwarded_allow_ips": self.cfg.forwarded_allow_ips. That is gunicorn's own setting. Proxy headers stay on, since the worker does not change uvicorn's proxy_headers default.

So both of these work:

bash
gunicorn -k uvicorn_worker.UvicornWorker --forwarded-allow-ips 172.18.0.0/16 main:app
FORWARDED_ALLOW_IPS=172.18.0.0/16 gunicorn -k uvicorn_worker.UvicornWorker main:app

We ran both against gunicorn 26.2.0 and uvicorn-worker 0.4.0 behind nginx, with a CIDR value and with *. Each one changed the client the app saw, and the default did not. The last comment on #589, from February 2026, reached the same answer: "Seems it is working fine."

In this mode, uvicorn's code handles the request, not gunicorn's HTTP parser. The client address and X-Forwarded-Proto follow uvicorn's rules. Gunicorn's secure_scheme_headers do not apply.

Hypercorn: a hop count, and no peer check#

Hypercorn has no forwarded-allow-ips setting. Out of the box it reports the peer, nginx. To change that, you wrap the app yourself:

python
from hypercorn.middleware import ProxyFixMiddleware

fixed_app = ProxyFixMiddleware(app, mode="legacy", trusted_hops=1)

mode="legacy" reads X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host. mode="modern" reads the RFC 7239 Forwarded header. trusted_hops is how many proxies sit in front. The hypercorn guide says it "should be set to the number of proxies that are chained in front of Hypercorn".

The code in proxy_fix.py (0.18.0) takes the entry that many places from the right. With trusted_hops=1 that is the last entry, the one nginx wrote. It does not look at the peer at all, so a UNIX socket changes nothing. Anyone who can reach hypercorn's port directly can set their own client address, scheme and Host. The guide's warning covers it: the mode and hop count "must match your setup or the user-agent (client) may be trusted". The middleware also copies X-Forwarded-Proto into the scope without checking the value.

X-Forwarded-Proto is gated by the same list#

The scheme follows the same trust rule as the address. So a wrong list causes two symptoms at once. Behind a TLS proxy, the app sees http. It builds http:// links, and it may loop on its own HTTPS redirect.

  • Uvicorn reads X-Forwarded-Proto only from a trusted peer. It accepts only http, https, ws and wss, and drops any other value. On a WebSocket it turns https into wss. When the header appears more than once, the last line wins, as the deployment docs say.
  • Gunicorn WSGI workers set wsgi.url_scheme to https when a trusted peer sends a header that matches secure_scheme_headers. The default dictionary is X-FORWARDED-PROTOCOL: ssl, X-FORWARDED-PROTO: https and X-FORWARDED-SSL: on. The settings page warns that if two of these disagree, the request fails with InvalidSchemeHeaders.
  • Hypercorn with ProxyFixMiddleware uses the same hop count as for the address.

The redirect loop and the other X-Forwarded-* headers are covered in X-Real-IP, X-Forwarded-Proto, Host and Port.

UNIX sockets: the peer has no address#

Uvicorn's own nginx example connects through a UNIX socket, with server unix:/tmp/uvicorn.sock; in the upstream. That is the case most likely to surprise you.

A UNIX socket has no IP address. Uvicorn reads the peer in get_remote_addr. For a UNIX socket the peer name is not an address pair, so the function returns None. The deployment docs say it plainly: "When Uvicorn is running behind a UDS the initial client will be None." The middleware then checks None against the list. Its __contains__ returns False for an empty host before it looks at any entry. So:

  • request.client is None in Starlette and FastAPI. Code that reads request.client.host raises AttributeError: 'NoneType' object has no attribute 'host'.
  • X-Forwarded-For and X-Forwarded-Proto are ignored, so the scheme stays http.
  • Putting the socket path in --forwarded-allow-ips does not help. The docs give a socket path as an example literal. But the check compares the peer's host, and on a socket that host is None, not the path. We tried uvicorn 0.54.0 with the socket path and unix: both on the list. The client stayed None and the scheme stayed http.
  • --forwarded-allow-ips='*' is the only value that works over a socket.

The same holds for gunicorn with the uvicorn worker on a unix: bind. The same uvicorn code runs.

Gunicorn's WSGI workers go the other way. Their docs say forwarded_allow_ips "does not affect UNIX socket connections. Connections not associated with an IP address are treated as allowed, unconditionally." So over a socket, gunicorn believes the scheme headers with no setting at all. REMOTE_ADDR is still not taken from X-Forwarded-For. On our socket test it was an empty string.

The literals do have one use. When nginx itself listens on a UNIX socket, behind some other proxy, its $remote_addr is unix:. The deployment docs say nginx "will add the literal unix: as the client in the X-Forwarded-For header." Uvicorn's walk would stop at that entry and report unix: as the client. Add unix: to the list and the walk skips it.

Why * is risky, and when it is not#

* changes more than the peer check. Most people expect * to mean "trust every peer, then walk the list as usual". In uvicorn's code it does not. With *, get_trusted_client_address skips the walk. It returns the leftmost entry, which the visitor's own request can supply.

Now look at what nginx sends. $proxy_add_x_forwarded_for is, in the nginx docs, the client's X-Forwarded-For "with the $remote_addr variable appended to it". A visitor who sends X-Forwarded-For: 1.2.3.4 reaches uvicorn as 1.2.3.4, 203.0.113.50. With a CIDR list, uvicorn walks from the right and gets 203.0.113.50, the real address. With *, it takes 1.2.3.4. We saw this happen in our check, on TCP and on a socket alike.

So * is safe only when both of these hold:

  1. Nothing can reach the app except nginx. No published port, no shared network with untrusted containers, no socket that other users can open. Uvicorn creates its --uds socket with mode 0o666 (from config.py). Any local user who can reach the directory can connect.
  2. nginx replaces the header instead of appending to it: proxy_set_header X-Forwarded-For $remote_addr;. That is what uvicorn's docs mean by "your proxies remove the existing headers before setting their own".

On TCP, you rarely need *. Trust nginx's address or network instead, and keep $proxy_add_x_forwarded_for. On a UNIX socket, * is the only choice, so pair it with the overwrite. Client IP spoofing through proxies shows what an attacker does with a header like this.

Worked case: FastAPI in Docker Compose behind nginx#

Take a Compose project with two services, nginx and api. The api service runs uvicorn main:app --host 0.0.0.0 --port 8000. Suppose Docker gives the project network 172.18.0.0/16, and nginx gets 172.18.0.3. A visitor at 203.0.113.50 loads a page, and the app logs request.client.host. Walk it through.

  1. nginx receives the request from 203.0.113.50. Its config has proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;. The visitor sent no such header, so nginx sends X-Forwarded-For: 203.0.113.50.
  2. Uvicorn receives the request from 172.18.0.3. Its list is the default, 127.0.0.1,::1. The peer is not on it.
  3. The middleware does nothing. The app logs 172.18.0.3, the nginx container.

Now add the network to the trusted list in the api service:

yaml
services:
  api:
    command: uvicorn main:app --host 0.0.0.0 --port 8000
    environment:
      FORWARDED_ALLOW_IPS: "172.18.0.0/16"
    # no "ports:" here; only nginx should reach port 8000
  1. The peer 172.18.0.3 is inside 172.18.0.0/16, so uvicorn reads the header.
  2. It walks 203.0.113.50 from the right. That address is not trusted, so it becomes the client, with port 0. The app logs 203.0.113.50.
  3. A visitor who sends X-Forwarded-For: 1.2.3.4 arrives as 1.2.3.4, 203.0.113.50. The walk stops at 203.0.113.50 again. The fake entry on the left is never reached.

Two choices in that file matter. The subnet is a guess until you check it. docker network inspect shows the real one. Docker can pick a different one for a new project. And the api service publishes no port. If it did, a request from the Docker host itself would arrive from the gateway, 172.18.0.1. That address is inside the trusted range, so such a request could set its own client address.

Where this answer stops applying#

The rows come from the source of uvicorn 0.54.0, uvicorn-worker 0.4.0, gunicorn 26.2.0, hypercorn 0.18.0 and Werkzeug 3.1.9. Other versions can differ. The clearest case is the 2020 worker described in issue #589, which ignored gunicorn's setting. We also ran the uvicorn, gunicorn sync and uvicorn-worker rows behind nginx 1.27 on Docker 28.1.1, over TCP and over a UNIX socket. The hypercorn socket case, the gthread, gevent and asgi workers, and Docker Desktop come from reading the code only. Frameworks add a layer on top. Middleware in Starlette, Django or Flask can rewrite the client again after the server. That is outside this page. You may have more than one proxy in front, such as a CDN or a cloud load balancer. Then every hop's address has to be on the list. If one is missing, the walk stops there. This page covers the server side only. Whether nginx itself sees the real visitor is a separate question. On cloud platforms it often does not. The client IP resolver shows what a given header and trusted list resolve to.

Frequently asked questions#

Why does FastAPI request.client.host show 172.17.0.1 or 172.18.0.x?#

That is the address nginx connects from. It is nginx's container address, or the bridge gateway when nginx runs on the host and uses a published port. Uvicorn trusts only 127.0.0.1 and ::1 by default, so it ignores X-Forwarded-For from that address. Set --forwarded-allow-ips or FORWARDED_ALLOW_IPS to nginx's address or network.

Does gunicorn --forwarded-allow-ips fix REMOTE_ADDR?#

No. With gunicorn's own WSGI workers, REMOTE_ADDR is always the socket peer. The setting decides who may send the scheme headers and the forwarder_headers, nothing else. Use Werkzeug ProxyFix in Flask, middleware in Django, or the PROXY protocol.

Does the uvicorn worker for gunicorn honor --forwarded-allow-ips?#

Yes. uvicorn-worker 0.4.0 passes gunicorn's forwarded_allow_ips value to uvicorn's config. Uvicorn issue #589 reported the opposite in 2020, and it was closed that year. Setting FORWARDED_ALLOW_IPS in the environment works too, since both programs read it.

Why is request.client None with uvicorn --uds?#

A UNIX socket has no IP address, so uvicorn sets the initial client to None. Its proxy middleware treats an empty peer as untrusted, so it never reads X-Forwarded-For. Only --forwarded-allow-ips='*' makes it read the header over a socket. Pair that with proxy_set_header X-Forwarded-For $remote_addr; in nginx.

Is --forwarded-allow-ips='*' safe behind nginx?#

Only if nothing but nginx can reach the app and nginx overwrites X-Forwarded-For with $remote_addr. With *, uvicorn takes the leftmost entry. With nginx's usual $proxy_add_x_forwarded_for, that leftmost entry is whatever the visitor sent.

Why is the scheme http behind nginx with TLS?#

Uvicorn reads X-Forwarded-Proto only from peers on the same trusted list as the address. If nginx is not on the list, the scheme stays http, and so does the address. Fixing the list fixes both. nginx must also send proxy_set_header X-Forwarded-Proto $scheme;.

What should forwarded-allow-ips be in Docker Compose?#

Use nginx's network, from docker network inspect, such as 172.18.0.0/16. Or use nginx's own address if you pin it. Do not publish the app's port. Connections from the host through a published port arrive from the network gateway, which is inside that range.

Primary sources#

Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.

  1. Uvicorn settings (--proxy-headers, --forwarded-allow-ips)
  2. Uvicorn deployment, Proxies and Forwarded Headers (client port, UNIX sockets, trust everything)
  3. Uvicorn 0.54.0 source, uvicorn/middleware/proxy_headers.py (ProxyHeadersMiddleware)
  4. Uvicorn 0.54.0 source, uvicorn/protocols/utils.py (get_remote_addr)
  5. Uvicorn 0.54.0 source, uvicorn/config.py (FORWARDED_ALLOW_IPS default, UNIX socket mode 0o666)
  6. uvicorn-worker 0.4.0 source, uvicorn_worker/_workers.py
  7. Uvicorn issue #589: Uvicorn via gunicorn worker doesn't respect --forwarded-allow-ips
  8. Uvicorn issue #1068: ProxyHeadersMiddleware with client on trusted proxy
  9. Gunicorn settings (forwarded_allow_ips, secure_scheme_headers, forwarder_headers, proxy_protocol)
  10. Gunicorn 26.2.0 source, gunicorn/http/wsgi.py (REMOTE_ADDR)
  11. Gunicorn 26.2.0 source, gunicorn/http/message.py (forwarded trust check)
  12. Gunicorn 26.2.0 source, gunicorn/asgi/protocol.py (asgi worker scope client)
  13. Hypercorn 0.18.0, Fixing proxy headers guide
  14. Hypercorn 0.18.0 source, middleware/proxy_fix.py
  15. Werkzeug 3.1.9 source, middleware/proxy_fix.py (ProxyFix defaults)
  16. nginx ngx_http_proxy_module (proxy_set_header, $proxy_add_x_forwarded_for)

Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.

More in client ip and forwarding headers#