skip to content

In Django, how do request.headers and request.META differ, and how do you read a webhook's X-Signature header from each?

level: middleimportance: should knowfreq 40%

answer

  1. the gateway's naming scheme
  2. HTTP_ prefix, upper case, underscores
  3. two content headers are special
  4. case-insensitive mapping for headers

basics

~10 s

request.META is the raw server environment: headers appear as HTTP_X_SIGNATURE alongside variables like REMOTE_ADDR. request.headers is a case-insensitive mapping of HTTP headers only, read as request.headers['X-Signature'].

solid answer

~30 s

`request.META` is a plain dict of the server environment. It holds every HTTP header, renamed to upper case with hyphens as underscores and an `HTTP_` prefix, so `X-Signature` becomes `HTTP_X_SIGNATURE`, except `CONTENT_TYPE` and `CONTENT_LENGTH`, which have no prefix. It also holds server variables such as `REMOTE_ADDR`, `QUERY_STRING` and `SERVER_NAME`. `request.headers` is a read-only, case-insensitive mapping of the headers alone under their normal names, so `request.headers["X-Signature"]` and `request.headers["x-signature"]` both work, as does the underscore form templates need. Application code should prefer `headers`. In tests, pass `headers={"X-Signature": ...}` to the test client or the META-style `HTTP_X_SIGNATURE=` keyword.

go deeper

for a junior

Recall that request.headers['X-Signature'] reads a header by its normal name and that META uses HTTP_X_SIGNATURE.

for a middle

Explain the gateway naming rules, the unprefixed CONTENT_TYPE and CONTENT_LENGTH, the extra server variables in META, and how the test client sends headers.

for a senior

Handle headers defensively: prefer request.headers, know about underscore spoofing, and never treat REMOTE_ADDR or forwarding headers as the client address without proxy configuration.

for a principal

Set conventions for which request data code may trust, so header-derived identity and addresses are handled in one audited place.

## Two views of the same headers Django builds every request from the server's environment: the WSGI `environ` dict or the ASGI `scope`. It exposes that environment twice. | | `request.META` | `request.headers` | |---|---|---| | Type | a plain `dict` | a read-only, **case-insensitive** mapping (`HttpHeaders`) | | Contains | every environment variable: HTTP headers **and** server variables | HTTP headers only | | Header key format | `HTTP_` + upper case, hyphens as underscores: `HTTP_X_SIGNATURE` | the header name, title-cased whatever case was sent: `X-Signature` | | Content headers | `CONTENT_TYPE`, `CONTENT_LENGTH` with **no** `HTTP_` prefix | `Content-Type`, `Content-Length` | | Lookup | exact key only | any case; underscores also work: `request.headers["x_signature"]` | | Extra keys | `REMOTE_ADDR`, `REMOTE_HOST`, `SERVER_NAME`, `SERVER_PORT`, `QUERY_STRING`, `REQUEST_METHOD`, `PATH_INFO`, `SCRIPT_NAME`, … | none | The `HTTP_` translation is not Django's invention: it is how the gateway interface names headers. `request.headers` exists to undo it. ## Reading a webhook's signature header A payment provider sending `X-Signature: t=1727350000,v1=5f2b…` can be read either way: ```python signature = request.headers.get("X-Signature", "") signature = request.META.get("HTTP_X_SIGNATURE", "") ``` Prefer **`request.headers`** in application code: it matches the name in the provider's documentation, it is case-insensitive, and it cannot be confused with server variables. In templates, where hyphens cannot be written in a variable lookup, the underscore form works: `{{ request.headers.user_agent }}`. Use **`request.META`** when you need something that is not a header: - `REMOTE_ADDR`, the address of whatever opened the connection, which behind a reverse proxy is the proxy, not the client; - `QUERY_STRING`, the raw query string; - `SERVER_NAME` and `SERVER_PORT`, used by `get_host()` only when no `Host` header was sent. ## The same shape under ASGI Under ASGI there is no WSGI `environ`, but Django keeps the contract: `ASGIRequest` builds `request.META` from the ASGI scope using the same names. Header names are upper-cased with hyphens turned into underscores and an `HTTP_` prefix, `Content-Type` and `Content-Length` become `CONTENT_TYPE` and `CONTENT_LENGTH`, and the client address from the scope becomes `REMOTE_ADDR`. Repeated headers are joined with commas, except `Cookie`, whose parts are joined with `; `, and any header whose name contains an underscore is skipped. Code written against `request.headers` or `request.META` therefore runs unchanged on both kinds of server. ## Underscores and spoofing Because the gateway turns hyphens into underscores, `X-Signature` and `X_Signature` would collide as `HTTP_X_SIGNATURE`. A client could send the underscore variant to shadow a header set by a proxy. For exactly this reason Django drops every header whose name contains an underscore in two places it controls: its development server strips them before building the WSGI environment, and `ASGIRequest` skips them when it builds `request.META` from the ASGI scope. Behind a production WSGI server, whether underscore headers reach Django depends on that server's configuration. ## Sending headers in tests The test client accepts both styles: 1. **`headers={"X-Signature": sig}`**: the readable form; Django converts it to `HTTP_X_SIGNATURE` for you. 2. **`HTTP_X_SIGNATURE=sig`** as an extra keyword argument: the older `META`-style form, still supported. ```python from django.test import TestCase class PaymentWebhookTests(TestCase): def test_rejects_bad_signature(self): response = self.client.post( "/payments/webhook/", data=b'{"type": "payment.succeeded"}', content_type="application/json", headers={"X-Signature": "not-a-valid-signature"}, ) self.assertEqual(response.status_code, 403) ``` ## Related request attributes - **`request.method`**: the HTTP method in upper case, such as `"POST"`. - **`request.content_type`** and **`request.content_params`**: the parsed `Content-Type` header, media type and parameters apart. - **`request.COOKIES`**: the parsed `Cookie` header as a dict of strings; the raw header is also in `request.headers["Cookie"]`. ## Common headers and the better accessor Several headers have a dedicated accessor that does more than a raw read: | Header | Raw read | Better accessor | |---|---|---| | `Host` | `request.headers["Host"]` | `request.get_host()`, which validates against `ALLOWED_HOSTS` | | `Cookie` | `request.headers["Cookie"]` | `request.COOKIES`, already parsed | | `Content-Type` | `request.headers["Content-Type"]` | `request.content_type` and `request.content_params` | | `Accept` | `request.headers["Accept"]` | `request.accepts("application/json")`, or `request.get_preferred_type([...])` since Django 5.2 | | `X-Signature` (custom) | `request.headers["X-Signature"]` | none: the raw read is the accessor | The raw `Host` value in particular should never be used to build URLs or pick a tenant, because it is whatever the client sent. ## What interviewers listen for - Knowing that `request.META["X-Signature"]` never works, and why. - Knowing that `Content-Type` is `CONTENT_TYPE`, not `HTTP_CONTENT_TYPE`. - Not trusting `REMOTE_ADDR` or client-supplied forwarding headers as the client's address without a deliberate proxy configuration.

  • Why do Django's development server and its ASGI request class drop request headers whose names contain underscores?
    The gateway interface maps both hyphens and underscores to underscores, so `X-Signature` and `X_Signature` would both become `HTTP_X_SIGNATURE`. A client could use the underscore form to shadow a header that a proxy sets. Stripping underscore headers before building the environment removes that ambiguity.
  • In Django, why is request.META['REMOTE_ADDR'] often not the client's address in production?
    `REMOTE_ADDR` is whatever opened the connection to the app server. Behind a reverse proxy or load balancer that is the proxy itself. The client's address then travels in forwarding headers the proxy adds, which Django does not trust automatically; reading them safely is a proxy-configuration decision.

saying these in an interview costs you the question

  • request.META['X-Signature'] reads the header as the client sent it.
  • Content-Type appears in request.META as HTTP_CONTENT_TYPE.
  • request.headers is case-sensitive, so the exact capitalization is required.
  • request.headers also contains REMOTE_ADDR and other server variables.
  • REMOTE_ADDR is always the end user's IP address.