Behind a proxy¶
When Veloce runs behind a reverse proxy (nginx, Caddy, an ALB, Cloudflare), the TCP peer is the proxy, not the original client, and the proxy may strip a path prefix before forwarding. ProxyFix recovers the original client IP, scheme, host, and prefix from the proxy's forwarding headers, and request.root_path / request.script_root expose the mounted prefix. This page covers both.
The two problems a proxy introduces¶
A reverse proxy changes what the application sees in two distinct ways. Each has its own fix:
| Symptom | What the proxy did | Fix |
|---|---|---|
request.client_host is the proxy IP, scheme is http behind TLS |
Terminated the connection and re-issued the request | ProxyFix reads X-Forwarded-* |
The app is served under /api but routes are registered at / |
Mounted the app under a prefix and stripped it | root_path / script_root |
Trusting forwarded headers with ProxyFix¶
A proxy injects headers describing the original request. Add ProxyFix and tell it how many hops to trust for each header.
from veloce import ProxyFix, Request, Veloce
app = Veloce()
app.add_middleware(ProxyFix(x_for=1, x_proto=1, x_host=1))
@app.get("/whoami")
async def whoami(request: Request):
return {
"client": request.client_host,
"scheme": request.url.scheme,
"host": request.host,
}
After the middleware runs:
request.client_hostreturns the IP fromX-Forwarded-Forinstead of the TCP peer.request.url.schemereflectsX-Forwarded-Proto(so redirects andrequest.urlsayhttpseven though TLS was terminated upstream).request.hostreflectsX-Forwarded-Host.
Trust depth is a security boundary
The numbers count trusted proxies from the right (closest to the app). A client can spoof these headers; only the proxies you control are trustworthy. With two proxies in front of you, set x_for=2 so the value attributed to the client is the one your outermost trusted proxy wrote. Setting trust too high lets a client forge its own IP or scheme.
Hop counts per header¶
Each field independently selects the Nth value from the right of its header. 0 disables that header entirely; negative values raise ValueError at construction.
| Field | Header | Default | Purpose |
|---|---|---|---|
x_for |
X-Forwarded-For |
1 |
Original client IP. |
x_proto |
X-Forwarded-Proto |
1 |
Original scheme (http/https). |
x_host |
X-Forwarded-Host |
0 |
Original Host header. |
x_port |
X-Forwarded-Port |
0 |
Public port when the forwarded Host carries none. |
x_prefix |
X-Forwarded-Prefix |
0 |
Stripped path prefix (feeds script_root). |
X-Forwarded-Host, X-Forwarded-Port, and X-Forwarded-Prefix default to 0 (off) — enable only the ones your proxy actually sets.
The standard Forwarded header¶
ProxyFix also reads the RFC 7239 Forwarded header. When both forms are present, Forwarded wins. Set trust_forwarded=False to ignore it and use only the X-Forwarded-* headers.
ProxyFix selects by trust depth, it does not authenticate
The middleware picks the value your trusted hop wrote; it does not verify the upstream chain cryptographically. Restrict who can reach the app directly (firewall, private network) so the only source of these headers is a proxy you operate. See RFC 7239 for the Forwarded grammar.
A stripped prefix: root_path and script_root¶
When the app is mounted under a prefix — by an ASGI server (uvicorn --root-path /api), by app.mount("/sub", inner_app), or by a proxy sending X-Forwarded-Prefix — the routes still register at /, but the public URL carries the prefix. Two request properties expose it:
from veloce import Request, Veloce
app = Veloce()
@app.get("/info")
async def info(request: Request):
return {
"root_path": request.root_path,
"script_root": request.script_root,
"path": request.path,
}
request.root_pathreturns the ASGIscope["root_path"]— set by the server or by a mount. Empty string at root.request.script_rootis the same value, except that aProxyFix-trustedX-Forwarded-Prefixwins over the ASGI scope, because that prefix is the trusted outer-edge value when the ASGI server itself sits behind a proxy that strips it.
You can also set the prefix on the application directly:
Combining a stripped prefix with ProxyFix¶
Enable x_prefix so the proxy's X-Forwarded-Prefix feeds script_root:
from veloce import ProxyFix, Request, Veloce
app = Veloce()
app.add_middleware(ProxyFix(x_for=1, x_proto=1, x_host=1, x_prefix=1))
@app.get("/items")
async def items(request: Request):
return {"mounted_under": request.script_root}
url_for behind a proxy¶
url_for reverse-resolves a route name to a path. It builds from the registered route template and does not prepend root_path or script_root, so a relative result is root-relative to the application, not to the public mount point.
from veloce import Request, Veloce
app = Veloce()
@app.get("/items/{item_id:int}", name="item")
async def item(request: Request, item_id: int):
return {"url": request.url_for("item", item_id=item_id)}
For absolute URLs, pass _external=True. Veloce uses app.config["SERVER_NAME"] and app.config["PREFERRED_URL_SCHEME"] (or override with _scheme= / _host=):
app.config["SERVER_NAME"] = "example.com"
app.config["PREFERRED_URL_SCHEME"] = "https"
# url_for("item", item_id=7, _external=True) -> "https://example.com/items/7"
url_for does not add the proxy prefix
Behind a stripped prefix, url_for("item", item_id=7) returns /items/7, not /api/items/7. To produce public links that include the mount prefix, prepend request.script_root yourself, or set _host= to the public host for absolute URLs. Veloce does not splice the prefix into reversed URLs automatically.
Testing it¶
Drive the forwarding headers through TestClient and assert the recovered values.
from veloce import ProxyFix, Request, TestClient, Veloce
app = Veloce()
app.add_middleware(ProxyFix(x_for=1, x_proto=1, x_host=1))
@app.get("/whoami")
async def whoami(request: Request):
return {
"client": request.client_host,
"scheme": request.url.scheme,
"host": request.host,
}
client = TestClient(app)
resp = client.get(
"/whoami",
headers={
"X-Forwarded-For": "203.0.113.7",
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "example.com",
},
)
assert resp.status_code == 200
assert resp.json() == {
"client": "203.0.113.7",
"scheme": "https",
"host": "example.com",
}
Next steps¶
- Middleware — where
ProxyFixsits in the request pipeline and how to order it. - Configuration — set
SERVER_NAMEandPREFERRED_URL_SCHEMEfor absoluteurl_for. - Deployment — run the app behind a real proxy in production.
- Full signatures are in the API reference.