Behind a TLS-terminating proxy that strips a /payments prefix, why does Django's request.build_absolute_uri() produce an http:// callback URL without the prefix?
answer
- scheme, host, path
- what the app server saw
- path versus path_info
- script prefix feeds reverse()
basics
~20 sbuild_absolute_uri() joins request.scheme, get_host() and the path as the app server saw them. The proxy spoke plain HTTP, so is_secure() is False, and without SCRIPT_NAME or FORCE_SCRIPT_NAME Django knows no prefix, so reverse() and request.path omit /payments.
solid answer
~30 s`request.build_absolute_uri(location)` is `request.scheme` plus `request.get_host()` plus the location, or the full path when none is given. The scheme comes from the gateway, and the proxy forwarded plain HTTP, so it is `http` and `is_secure()` is `False` until `SECURE_PROXY_SSL_HEADER` (default `None`) names a header the proxy sets. The path problem is `path` versus `path_info`: patterns match `path_info`, while `path` and every URL `reverse()` builds include the script prefix taken from `SCRIPT_NAME`. A proxy that strips `/payments` without passing `SCRIPT_NAME` leaves that prefix empty; setting `FORCE_SCRIPT_NAME = "/payments"` fixes it. The host must also be in `ALLOWED_HOSTS`, or `get_host()` raises `DisallowedHost`.
code
python · 8 lines# settings.py (production)
ALLOWED_HOSTS = ["shop.example.com"]
# The proxy strips /payments before forwarding; tell Django about it.
FORCE_SCRIPT_NAME = "/payments"
# The proxy terminates TLS and sets this header on every request it forwards.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")go deeper
Recall that build_absolute_uri() adds scheme and host to a path, and that is_secure() reports whether Django thinks the request used HTTPS.
Explain path versus path_info, the script prefix that reverse() prepends, and where request.scheme and get_host() take their values from.
Diagnose proxied deployments: wrong scheme, lost prefix and DisallowedHost, fixed with FORCE_SCRIPT_NAME, a safely configured SECURE_PROXY_SSL_HEADER and ALLOWED_HOSTS.
Decide whether absolute URLs come from the request or from a configured base URL, given background jobs and multiple entry points that have no request to ask.
## The symptom A Django app sits behind a reverse proxy that terminates TLS and forwards requests for `https://shop.example.com/payments/...` to the app, stripping the `/payments` prefix. When the app registers its webhook with the payment provider, it builds the callback URL with: ```python callback = request.build_absolute_uri(reverse("webhooks:payment")) ``` and gets `http://shop.example.com/webhook/payment/`: wrong scheme, missing prefix. Three request attributes explain it. ## `build_absolute_uri()` is scheme + host + path `request.build_absolute_uri(location)` combines: 1. **`request.scheme`**, which is `"https"` only when Django believes the request was secure; `is_secure()` simply checks that value; 2. **`request.get_host()`**, the `Host` header validated against `ALLOWED_HOSTS` (or `X-Forwarded-Host` when `USE_X_FORWARDED_HOST` is enabled, which it is not by default); 3. the **location**, or `request.get_full_path()` when none is given. A relative location is joined to the current path. Each piece comes from what the app server saw, not from what the browser typed. ## Why the scheme is `http` `request.scheme` comes from the gateway's `wsgi.url_scheme` (or the ASGI scope). The proxy talked to the app over plain HTTP, so that value is `http`, and `is_secure()` returns `False`. Django only reports `https` for proxied traffic when `SECURE_PROXY_SSL_HEADER` is set, naming a header the proxy sets and the value that means "the client used HTTPS". The setting defaults to `None`, and configuring it safely, so clients cannot forge that header, is a proxy-trust decision in its own right. ## Why the prefix is missing: `path` versus `path_info` Django splits the URL path in two: | Attribute | Meaning | Example | |---|---|---| | `request.path_info` | the part URL patterns match, after any mount prefix | `/webhook/payment/` | | `request.path` | the full path including the **script prefix** (`SCRIPT_NAME`) | `/payments/webhook/payment/` | | `request.get_full_path()` | `path` plus the query string | `/payments/webhook/payment/?retry=1` | At the start of each request Django records the script prefix, and `reverse()` prepends it to every URL it builds. When the proxy strips `/payments` but does not tell the app server about it, `SCRIPT_NAME` is empty, `path` equals `path_info`, and `reverse()` produces `/webhook/payment/`. The fix is to make the prefix known: have the server pass `SCRIPT_NAME`, or set **`FORCE_SCRIPT_NAME = "/payments"`**, which overrides whatever the environment says. ## How `get_host()` picks the host `request.get_host()` tries three sources in order, then validates the result: 1. `X-Forwarded-Host`, but only when `USE_X_FORWARDED_HOST = True` (default `False`); 2. the `Host` header; 3. `SERVER_NAME` plus a non-default `SERVER_PORT`, when no `Host` header was sent. The chosen value must match `ALLOWED_HOSTS`, or Django raises `DisallowedHost`, which the client sees as a 400. A proxy that rewrites `Host` to an internal name therefore breaks `build_absolute_uri()` in a different way: the URL carries the internal name, or the request is rejected outright. Either pass the public `Host` through or enable `USE_X_FORWARDED_HOST` for a proxy that sets the forwarded header reliably. ## The corrected setup - **Prefix**: `FORCE_SCRIPT_NAME` (default `None`) or a correct `SCRIPT_NAME`, so `reverse()` and `request.path` include `/payments`. - **Scheme**: `SECURE_PROXY_SSL_HEADER` configured to match the proxy, so `is_secure()` is `True` and `build_absolute_uri()` produces `https`. - **Host**: the public host name in `ALLOWED_HOSTS`, so `get_host()` accepts it instead of raising `DisallowedHost`. ## Other places the same attributes leak 1. **Redirects after login** built from `request.get_full_path()` lose the prefix in the same way. 2. **`SECURE_SSL_REDIRECT`** loops forever behind a TLS proxy when `is_secure()` never becomes `True`, because every redirect to HTTPS arrives as HTTP again. 3. **Links in emails** built with `build_absolute_uri()` inherit the wrong scheme. 4. **Background jobs** have no request at all; they need the site's base URL from a project setting rather than `build_absolute_uri()`. ## Diagnosing it quickly Log or print, from one request: - `request.scheme` and `request.is_secure()`; - `request.get_host()`; - `request.path`, `request.path_info` and `request.META.get("SCRIPT_NAME")`. Mismatches between these and the public URL point straight at the missing setting.
- In Django, what are request.path and request.path_info for an app mounted at /payments?For `https://shop.example.com/payments/webhook/payment/` with the prefix known, `path_info` is `/webhook/payment/`, the part URL patterns match, and `path` is `/payments/webhook/payment/`, the prefix included. Without a known prefix the two are equal, which is the symptom of a missing `SCRIPT_NAME` or `FORCE_SCRIPT_NAME`.
- Why can SECURE_SSL_REDIRECT cause a redirect loop behind a TLS-terminating proxy in Django?The redirect fires whenever `request.is_secure()` is `False`. Behind the proxy every request reaches Django over HTTP, so every request, including the one that followed the redirect, looks insecure and is redirected again. Configuring `SECURE_PROXY_SSL_HEADER` for the proxy makes `is_secure()` true for HTTPS clients and ends the loop.
saying these in an interview costs you the question
- build_absolute_uri() uses the URL the browser typed, so the proxy cannot affect it.
- is_secure() checks whether the public site has a TLS certificate.
- request.path and request.path_info are always identical.
- reverse() never knows about a mount prefix, so it must be added by hand.
- Setting DEBUG = False makes Django trust forwarded scheme headers.