Fix Nginx Dropping Custom Headers With Underscores Before Django
Fix custom request headers such as X_API_KEY or tenant_id disappearing behind Nginx: understand why Nginx ignores headers with underscores, rename them to use hyphens, or enable underscores_in_headers, and read them correctly in Django’s request.headers.
Problem statement
A mobile app or partner integration sends a custom header such as X_API_KEY: abc123 or tenant_id: 42. It works against runserver and Gunicorn directly. Behind Nginx, Django never sees it: request.headers.get("X_API_KEY") returns None, and the API rejects the request.
Nginx silently ignores request headers whose names contain underscores. The setting underscores_in_headers is off by default, and ignore_invalid_headers is on, so such headers are dropped before proxying. Headers with hyphens, like X-Api-Key, pass normally.
- Rename the header to use hyphens:
X-Api-Keyinstead ofX_API_KEY. This is the standard form. - Read it in Django with
request.headers["X-Api-Key"](case-insensitive) orrequest.META["HTTP_X_API_KEY"]. - If you cannot change clients, add
underscores_in_headers on;to the Nginxserverblock (orhttpblock). - Check load balancers and CDNs in front of Nginx too; some also drop or normalize such headers.
- Be aware that WSGI maps
-and_to the sameHTTP_key, so do not rely on the difference.
Step-by-step solution
1. Confirm Nginx drops it
curl -s -H "X_API_KEY: test" http://127.0.0.1:8000/debug/headers/ # straight to Gunicorn
curl -s -H "X_API_KEY: test" https://api.example.com/debug/headers/ # through Nginx
If the first shows the header and the second does not, Nginx is dropping it. With log_format including $http_x_api_key, you can also see that Nginx itself does not register the value.
2. Prefer hyphenated header names
HTTP header names conventionally use hyphens. Changing the client to send X-Api-Key works through every proxy without configuration. In Django:
api_key = request.headers.get("X-Api-Key")
# equivalent: request.META.get("HTTP_X_API_KEY")
request.headers is case-insensitive and accepts the hyphenated form; request.META uses the CGI-style name, upper case with underscores and an HTTP_ prefix.
3. Allow underscores when clients cannot change
server {
listen 443 ssl;
server_name api.example.com;
underscores_in_headers on;
location / {
proxy_pass http://app;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
The directive is valid in http and server contexts. When several server blocks listen on the same address, Nginx uses the setting from the default server for that address and port, so set it in the http block or on the default server to be sure. Run sudo nginx -t && sudo systemctl reload nginx.
4. Check layers in front of Nginx
AWS ALB and Cloudflare pass underscore headers in most configurations, but other proxies, WAFs, and API gateways may not. Test from the public internet after changing Nginx, not only from inside the network.
5. Understand why Nginx is cautious
Under WSGI and CGI, both X-Api-Key and X_API_KEY become HTTP_X_API_KEY. A client could send both, and which one the application sees depends on the server. That ambiguity has been used to spoof headers that a proxy sets, such as an authenticated user header. If you enable underscores, make sure Nginx overwrites any security-relevant headers it sets for Django, so a client cannot smuggle in an underscore variant.
Explanation
RFC 7230 allows underscores in header names, but CGI's convention of converting header names to environment variables by replacing hyphens with underscores means two different headers can collide. Nginx avoids the collision by treating underscore names as invalid and, with ignore_invalid_headers on, dropping them. Gunicorn and runserver pass them through (Gunicorn drops some ambiguous underscore headers too in newer versions), which is why the problem appears only behind Nginx.
Edge cases / notes
- Gunicorn. Recent Gunicorn versions also refuse or drop headers with underscores in some cases for the same security reason; hyphens avoid both.
- Logging. Nginx variables like
$http_x_api_keymatch hyphenated headers; underscore headers are not available unless allowed. - Case. Header names are case-insensitive;
x-api-keyandX-API-KEYare the same header.
Next steps
- For the Authorization header specifically, see Fix Django Authorization Header Missing Behind a Proxy.
- Review proxy headers with Configure Nginx for Django in Production.
- Check the rest of the config with the Django Nginx Production Checklist.
FAQ
Why does Nginx drop headers with underscores?
Because under CGI-style naming they collide with hyphenated headers, which can be abused for spoofing. Nginx drops them by default to avoid the ambiguity.
How do I read a custom header in Django?
Use request.headers.get("X-Api-Key"), which is case-insensitive, or request.META.get("HTTP_X_API_KEY").
Is underscores_in_headers on safe?
It is acceptable if Nginx always overwrites the security-relevant headers it sets. Renaming to hyphens is safer.
Why does it work with runserver?
The development server passes underscore headers through. Nginx does not by default.