Chapter 4 · Requests and responses
4.2 Headers, host and scheme
How a request’s headers are read from META through HttpHeaders, how HttpRequest and HttpRequest decide which of them to believe, how build_absolute_uri uses the answers, and how the "Accept" header is ranked.
A request’s headers reach Django inside META, mixed with everything else the server put there and spelt the way CGI spelt them: "HTTP_ACCEPT_LANGUAGE" for Accept-Language. Two questions come up for any code that reads them. The first is only a matter of convenience: how to look a header up by its own name. The second matters: which headers can be believed. A header is whatever the client chose to send, and a site that builds a password-reset link from the "Host" header, or decides a request was encrypted because a header says so, has let the client decide.
HttpHeaders
HttpRequest is a mapping from a header’s name to its value, and the name can be written in any case. It is an HttpHeaders, made the first time the attribute is read by going through META once and keeping the keys that are headers: those that begin with "HTTP_", with the prefix removed, and the two that WSGI leaves without one, "CONTENT_TYPE" and "CONTENT_LENGTH". Each key is turned back into a header’s usual spelling, underscores to hyphens and each word capitalised.
The mapping is read-only. Its base class, CaseInsensitiveMapping, keeps each entry under its name in lower case beside the name as it was written, and has no method that changes it. It is also a copy: it is made once and kept, so a middleware that changes META after something has read headers does not change what the next reader of headers sees.
A lookup may also write the name with underscores in place of hyphens, request. That spelling is there for templates, where a hyphen cannot appear in a variable’s name.
The class also runs the other way, for the test client, which lets a test give headers by their real names. HttpHeaders takes a dictionary keyed that way and returns it keyed as an environ would have it. HttpHeaders only puts each name in upper case with underscores, and the asynchronous half of the test client turns the result into the header names of a scope.
The host
HttpRequest answers the question of which host the client asked for, and refuses to answer when the host is not one the site has agreed to serve.
It looks in up to three places, in a fixed order. The X-Forwarded-Host header is used only when settings is true: a proxy sets that header, and so can anyone else, so it is believed only by a site that has said it stands behind a proxy. Otherwise the "Host" header is used. A request with neither gets the server’s own name from "SERVER_NAME", with the port added when it is not the usual one for the scheme.
The value is then checked against settings. split_domain_port lower-cases it, takes the port and a trailing dot off, and rejects anything that is not shaped like a host name or a bracketed IPv6 address. validate_host compares what is left with each pattern in the setting: "*" matches anything, a pattern that begins with a dot matches that domain and every subdomain of it, and any other pattern must be the host exactly.
request.get_host(), with ALLOWED_HOSTS = ['shop.example'] unless the line says otherwise
Host: shop.example -> 'shop.example'
Host: SHOP.example:8000 -> 'SHOP.example:8000'
Host: shop.example. (a trailing dot) -> 'shop.example.'
Host: www.shop.example -> raises DisallowedHost
Host: www.shop.example, and ALLOWED_HOSTS = ['.shop.example'] -> 'www.shop.example'
Host: shop.example/x (not a host name) -> raises DisallowedHost
no Host header; SERVER_NAME shop.example, SERVER_PORT 80 -> 'shop.example'
no Host header; SERVER_NAME shop.example, SERVER_PORT 8000 -> 'shop.example:8000'
Host: shop.example, X-Forwarded-Host: evil.example -> 'shop.example'
the same, and USE_X_FORWARDED_HOST = True -> raises DisallowedHost
Host: evil.example, and ALLOWED_HOSTS = ['*'] -> 'evil.example'
Host: evil.example, and ALLOWED_HOSTS = [], DEBUG = False -> raises DisallowedHost
Host: evil.example, and ALLOWED_HOSTS = [], DEBUG = True -> raises DisallowedHost
Host: app.localhost:8000, and ALLOWED_HOSTS = [], DEBUG = True -> 'app.localhost:8000'
The recording shows what the check does and does not change. The comparison is made on the normalised name, but what get_host returns is the value as it was sent, capitals, port and trailing dot included. An empty ALLOWED_HOSTS allows nothing, with one exception: when settings.DEBUG is true as well, the names a developer’s own machine goes by are allowed: "localhost" with its subdomains, "127.0.0.1" and "[::1]".
A host that fails raises DisallowedHost. That is a SuspiciousOperation, which the middleware chain turns into a 400 response and reports to the logger "django (How an exception becomes a response).
The check runs when get_host is called, not when the request is made. Nothing in the handler calls it. In a project with the middleware that django-admin startproject writes, every request is checked all the same, because CommonMiddleware calls get_host on its way in, once the request has passed that middleware’s check of its user agent; a request answered by a middleware listed before that one, or served by a project without it, is checked only if something asks for the host.
HttpRequest is simpler. It returns "SERVER_PORT", or the X-Forwarded-Port header when settings is true and the header is there, and it validates nothing.
The scheme
HttpRequest is "http" or "https", and HttpRequest says whether it is the second. What the server knows is the scheme of the connection it accepted: a WSGIRequest reads the environ’s "wsgi, an ASGIRequest the scope’s "scheme". Behind a proxy that ends TLS that is the wrong answer, since the connection from the proxy is plain HTTP when the client’s was not.
settings names a header and the value that means secure. When it is set and the request has that header, the header alone decides, in both directions: the scheme is "https" if the value matches and "http" if it does not, whatever the server said. Where the header holds a list, the first item is the one compared. Only when the header is absent does the server’s answer stand.
request.scheme
the server says http -> 'http'
the server says http, X-Forwarded-Proto: https -> 'http'
the same, and SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") -> 'https'
that setting, X-Forwarded-Proto: https, http (a list) -> 'https'
that setting, the server says https, no such header -> 'https'
that setting, the server says https, X-Forwarded-Proto: http -> 'http'
The scheme is a property that is worked out on every read, and a good deal hangs on it: whether SecurityMiddleware redirects to HTTPS, whether the CSRF middleware checks the "Referer" of a request that has no "Origin" (The CSRF check: CsrfViewMiddleware), what build_absolute_uri writes.
build_absolute_uri
HttpRequest turns a location into a full URL on the site the request came to. It is what code calls when a path is not enough: within Django, the cache keys a page by the request’s full URL. It takes the scheme and host from HttpRequest and get_host, so it raises DisallowedHost where get_host would, unless the location it is given already has a scheme and a host of its own.
request.build_absolute_uri, of a request for http://shop.example/orders/2026/?page=2
build_absolute_uri() -> 'http://shop.example/orders/2026/?page=2'
build_absolute_uri("/pay/") -> 'http://shop.example/pay/'
build_absolute_uri("pay/?step=1") -> 'http://shop.example/orders/2026/pay/?step=1'
build_absolute_uri("../2025/") -> 'http://shop.example/orders/2025/'
build_absolute_uri("//cdn.example/logo.png") -> 'http://cdn.example/logo.png'
build_absolute_uri("https://bank.example/pay/?to=é") -> 'https://bank.example/pay/?to=%C3%A9'
get_full_path() -> '/orders/2026/?page=2'
With no argument it is the request’s own URL. A location that already has a scheme and a host is left as it is, apart from the escaping every result gets. Anything else is resolved against the request’s URL as a browser would resolve a link on the page: a path from the root replaces the path, a relative one is joined to it, and a location that names a host and no scheme takes the request’s scheme. The plain case of a path from the root with no dot segments is done by joining strings, without the general algorithm.
The scheme and host together are kept after the first use as HttpRequest, so a view that builds many URLs validates the host once. HttpRequest is the path and the query string with neither scheme nor host, escaped for use in a URL; HttpRequest is the same for path_info.
The "Accept" header
A client says which kinds of response it can take with the "Accept" header: a list of media types, each of which may be a pattern such as "text/*" and may carry a weight from 0 to 1, written as a parameter named q. HttpRequest reads it into a list of MediaType objects and answers questions about it. Each item of the header becomes one MediaType, an item with a weight of zero is dropped, and a request with no such header is treated as accepting anything.
There are two orders to put such a list in, and the request keeps both.
Preference is what the client would most like. HttpRequest is the list sorted by it: the higher weight first, and at equal weight the more specific type first.
Precedence is which item of the header governs a given type, and there the more specific item wins whatever its weight: if a client sends "text/*" at 0.9 and "text/html" at 0.8, then HTML has weight 0.8, because the client said so about HTML in particular. HttpRequest is the same list sorted the other way, by how specific an item is first and by its weight second, and HttpRequest returns the first item of it that matches a given type.
Accept: text/html;q=0.8, application/json, text/*;q=0.9, */*;q=0.1
request.accepted_types -> application/json, text/*; q=0.9, text/html; q=0.8, */*; q=0.1
request.accepted_types_by_precedence -> application/json, text/html; q=0.8, text/*; q=0.9, */*; q=0.1
request.accepted_type("text/html") -> text/html; q=0.8
request.accepted_type("text/plain") -> text/*; q=0.9
request.get_preferred_type(["text/html", "application/json"]) -> 'application/json'
request.get_preferred_type(["text/html", "text/plain"]) -> 'text/plain'
request.accepts("image/png") -> True
HttpRequest is the method a view that can answer in several formats calls. It is given the types the view can produce, finds the governing item for each by precedence, and returns the type whose governing item the client prefers. The second call in the recording is the case the two orders exist for: offered HTML and plain text, it returns plain text, since HTML is governed by its own item at 0.8 and plain text by the pattern at 0.9. HttpRequest says only whether a type is acceptable at all.