Proxy Headers #
When Nginx forwards a request to the backend, it doesn’t just pass all headers through raw. Some headers get changed, some get removed, and some need to be added explicitly. Understanding these default behaviors — and how to control them precisely — is essential to ensure the backend gets the information it needs, and the client doesn’t get information it shouldn’t see.
Nginx’s Default Header Behavior #
When Nginx forwards a request to an upstream, it makes several modifications you might not be aware of:
flowchart LR
subgraph CLIENT["Request from Client"]
H1["Host: example.com"]
H2["X-Real-IP: (absent)"]
H3["Cookie: session=abc"]
H4["Authorization: Bearer ***"]
H5["Connection: keep-alive"]
H6["X-Custom_Header: value"]
H7["User-Agent: Chrome"]
end
subgraph NGINX["Nginx Modifications"]
direction TB
M1["Host → changed to\nthe backend hostname"]
M2["Connection → REMOVED"]
M3["X-Custom_Header → REMOVED\n(contains underscore)"]
M4["Other headers → forwarded"]
end
subgraph BACKEND["Received by Backend"]
B1["Host: localhost (CHANGED!)"]
B2["Cookie: session=abc (ok)"]
B3["Authorization: Bearer *** (ok)"]
B4["User-Agent: Chrome (ok)"]
B5["X-Real-IP: (absent - needs to be added)"]
end
CLIENT --> NGINX --> BACKENDWhat Gets Removed by Default #
| Header | Reason for Removal |
|---|---|
Connection | Hop-by-hop header — irrelevant for the next connection |
Keep-Alive | Hop-by-hop header |
Upgrade | Hop-by-hop header (needs to be re-added for WebSocket) |
Headers with underscores _ | Considered invalid by default |
What Gets Changed by Default #
| Header | Original Value | Value After Nginx |
|---|---|---|
Host | example.com | localhost or the backend IP |
This Host change is the most common cause of problems — a backend that does redirects or generates absolute URLs will use the wrong Host value.
Implications #
Without additional configuration, the backend doesn’t know:
- The client’s real IP (only sees Nginx’s IP)
- The domain the client accessed (
example.com) - Whether the original connection was HTTPS or HTTP
- The port the client used
proxy_set_header: Adding or Changing Request Headers to the Backend #
proxy_set_header is the most important directive for controlling headers sent to the backend.
Setting Client Information Headers (Mandatory for Every Proxy) #
location / {
proxy_pass http://backend;
# The original host the client accessed
# $host = domain from the client's Host header (without port)
# $http_host = original Host header including port if present
proxy_set_header Host $host;
# The client's real IP
proxy_set_header X-Real-IP $remote_addr;
# IP chain — combines the client IP with any X-Forwarded-For
# that may already exist (from a previous proxy)
# Example: "203.0.113.1" or "203.0.113.1, 10.0.0.1"
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Original connection scheme: "http" or "https"
proxy_set_header X-Forwarded-Proto $scheme;
# Server port
proxy_set_header X-Forwarded-Port $server_port;
# Host including port (useful for redirects)
proxy_set_header X-Forwarded-Host $host;
}
Removing Headers from Requests to the Backend #
Give an empty value to remove a header:
location /api/ {
proxy_pass http://api_backend;
# Example: a separate API backend that doesn't need to receive client cookies
proxy_set_header Cookie "";
# Remove Authorization before forwarding to a specific backend
# (useful if we have our own auth system at Nginx)
proxy_set_header Authorization "";
# Hide browser information from the backend
proxy_set_header User-Agent "NginxProxy/1.0";
}
Headers for Keepalive and HTTP/1.1 #
location / {
proxy_pass http://backend;
proxy_http_version 1.1;
# Remove the Connection header from the client
# so it doesn't interfere with keepalive to the backend
proxy_set_header Connection "";
}
Creating Reusable Header Snippets #
Because the same proxy header set is often used across many locations, store it in a snippet file to avoid duplication:
# /etc/nginx/snippets/proxy-headers.conf
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
Use it in your configuration:
server {
location /api/ {
proxy_pass http://api_backend;
include snippets/proxy-headers.conf;
# Add API-specific headers here if needed
}
location /admin/ {
proxy_pass http://admin_backend;
include snippets/proxy-headers.conf;
}
location /ws/ {
proxy_pass http://ws_backend;
include snippets/proxy-headers.conf;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
}
proxy_hide_header: Hiding Headers from the Backend Response #
By default Nginx forwards all headers from the backend response to the client. Use proxy_hide_header to prevent certain headers from reaching the client:
location / {
proxy_pass http://backend;
# ─── Headers that expose the backend technology ─────────────────────────────
# Hide them so attackers don't know the stack in use
proxy_hide_header X-Powered-By; # "PHP/8.2", "Express", "Django/4.2"
proxy_hide_header X-Runtime; # Processing time (Rails, etc.)
proxy_hide_header X-AspNet-Version; # Exposes the .NET version
proxy_hide_header Server; # Server header from the backend
# ─── Internal headers irrelevant to the client ─────────────────────
proxy_hide_header X-Request-Id; # Internal request ID
proxy_hide_header X-Trace-Id;
proxy_hide_header X-Debug-Info;
# ─── Sensitive headers ─────────────────────────────────────────────────
proxy_hide_header X-Internal-Token;
proxy_hide_header X-Service-Name;
}
X-Powered-By is one of the most important headers to hide. Headers like “PHP/8.2.0” or “Express 4.18.2” immediately tell attackers what technology is in use, so they can search for specific CVEs.
proxy_pass_header: Allowing Headers Blocked by Nginx #
Some headers are blocked by Nginx by default. Use proxy_pass_header to allow them through to the client:
location / {
proxy_pass http://backend;
# The Date header is usually overridden by Nginx
# Allow the backend's value through
proxy_pass_header Date;
# Allow the Server header from the backend (useful for custom server headers)
proxy_pass_header Server;
}
add_header: Adding Headers to the Client Response #
add_header adds headers to the response sent to the client — not to the backend. This directive is essential for security headers.
server {
location / {
proxy_pass http://backend;
# ─── Security Headers ─────────────────────────────────────────────────
# Prevent browsers from MIME-type sniffing
add_header X-Content-Type-Options "nosniff" always;
# Prevent the page from being loaded in an iframe (clickjacking protection)
add_header X-Frame-Options "SAMEORIGIN" always;
# Enforce HTTPS for all subsequent requests (1 year)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Control the Referer information sent during navigation
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Restrict browser features the page can access
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Content Security Policy (adjust to your application's needs)
# add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'" always;
# ─── Cache headers for API responses ────────────────────────────────
add_header Cache-Control "no-store, no-cache" always;
add_header Pragma "no-cache" always;
}
}
The always Parameter — Mandatory for Security Headers
#
Without the always parameter, add_header is only added for successful responses (2xx and 3xx). Security headers must be present on all responses including errors:
# ANTI-PATTERN: without always
add_header X-Frame-Options "SAMEORIGIN";
# → The header is absent on 403, 404, 500, etc.
# → Attackers can do clickjacking on error pages!
# CORRECT: with always
add_header X-Frame-Options "SAMEORIGIN" always;
# → The header is always present, including on errors
The add_header Inheritance Trap #
This is one of Nginx’s most unintuitive behaviors, and it often causes unnoticed security holes.
Rule: if a context (location, server) defines the add_header directive, it does not inherit add_header from its parent context.
server {
# Security headers at the server level
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin" always;
location / {
# No add_header here
# → Inherits all three headers from the server block ✓
proxy_pass http://main_backend;
}
location /api/ {
# There's an add_header here!
# → The three headers from the server block are NOT inherited ✗
# → Only Cache-Control is present on /api/ responses
add_header Cache-Control "no-store" always;
proxy_pass http://api_backend;
}
location /admin/ {
# Also has add_header → doesn't inherit from the server block ✗
add_header X-Robots-Tag "noindex" always;
proxy_pass http://admin_backend;
}
}
Above, responses from /api/ and /admin/ don’t have X-Frame-Options, X-Content-Type-Options, and Referrer-Policy. An admin page vulnerable to clickjacking!
Solution: Always Use a Snippet #
# /etc/nginx/snippets/security-headers.conf
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
server {
location / {
include snippets/security-headers.conf;
proxy_pass http://main_backend;
}
location /api/ {
include snippets/security-headers.conf; # ← Include all security headers
add_header Cache-Control "no-store" always; # Add the specific one
proxy_pass http://api_backend;
}
location /admin/ {
include snippets/security-headers.conf;
add_header X-Robots-Tag "noindex" always;
proxy_pass http://admin_backend;
}
}
Headers for CORS (Cross-Origin Resource Sharing) #
APIs accessed from a different domain require CORS headers. Configuring this at Nginx lets you manage CORS centrally without implementing it in every backend:
# /etc/nginx/snippets/cors-headers.conf
# Adjust the allowed origins
set $cors_origin "";
if ($http_origin ~* ^https://(app\.example\.com|admin\.example\.com)$) {
set $cors_origin $http_origin;
}
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Max-Age "3600" always;
server {
location /api/ {
# Handle preflight requests
if ($request_method = OPTIONS) {
include snippets/cors-headers.conf;
add_header Content-Length 0;
return 204;
}
include snippets/cors-headers.conf;
proxy_pass http://api_backend;
}
}
Verifying Headers #
# Check all headers received by the client
curl -I https://example.com/
# Check headers verbosely to see BOTH request and response headers
curl -v https://example.com/api/test 2>&1
# Check specific security headers
curl -I https://example.com/ | grep -i "x-frame\|x-content\|strict-transport"
# Check CORS headers for a cross-origin request
curl -H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: GET" \
-X OPTIONS \
https://example.com/api/ -I
# Online tools for auditing security headers:
# https://securityheaders.com
# https://observatory.mozilla.org
Debugging and Auditing Headers #
Seeing What Headers Are Sent to the Backend #
When debugging authentication or routing problems, you need to know exactly which headers the backend receives from Nginx:
# Temporarily in development — DON'T in production
location /api/ {
proxy_pass http://backend;
# Add debug headers to the request to the backend
proxy_set_header X-Debug-Remote-Addr $remote_addr;
proxy_set_header X-Debug-Scheme $scheme;
proxy_set_header X-Debug-Host $host;
proxy_set_header X-Debug-Request-URI $request_uri;
proxy_set_header X-Debug-Server-Name $server_name;
}
Another effective way: use the echo module or a simple backend that prints all received headers:
# Run a minimal server to see all received headers
# (using netcat)
nc -l 9999 &
# Temporarily change proxy_pass to netcat
# proxy_pass http://localhost:9999;
# Then make a request and look at the netcat output:
curl http://localhost/api/test
# The netcat output shows all the headers Nginx sends to the backend
Verifying Headers Received by the Client #
# See all response headers from the server
curl -s -I https://example.com/ | sort
# Check specific security headers
curl -s -I https://example.com/ | grep -iE \
'x-frame|x-content-type|strict-transport|referrer-policy|permissions-policy|content-security'
# See headers for a CORS-involving request
curl -s -I \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: GET' \
-X OPTIONS \
https://example.com/api/
# Download a securityheaders.com report via CLI
curl -s 'https://securityheaders.com/?q=https://example.com&hide=on&followRedirects=on' \
| grep -oP '(?<=<div class="score">).*?(?=</div>)'
Security Header Checklist #
Before deploying to production, make sure all these headers exist and have correct values:
# Simple bash script to check headers
URL="https://example.com"
HEADERS=$(curl -s -I $URL)
check_header() {
local header=$1
if echo "$HEADERS" | grep -qi "^$header:"; then
echo "✓ $header: $(echo "$HEADERS" | grep -i "^$header:" | head -1)"
else
echo "✗ $header: MISSING"
fi
}
check_header "Strict-Transport-Security"
check_header "X-Content-Type-Options"
check_header "X-Frame-Options"
check_header "Referrer-Policy"
check_header "Permissions-Policy"
check_header "Content-Security-Policy"
# Check what shouldn't be there
BAD_HEADERS="X-Powered-By X-Runtime X-AspNet-Version"
for h in $BAD_HEADERS; do
if echo "$HEADERS" | grep -qi "^$h:"; then
echo "⚠ $h: FOUND — needs to be hidden with proxy_hide_header"
fi
done
Summary #
- Nginx by default changes
Hostand removes some headers (Connection, hop-by-hop) — the backend doesn’t automatically know the client’s real IP.proxy_set_header Host $hostis mandatory so the backend gets the real domain, not the internal hostname.proxy_hide_header X-Powered-By— hide headers that expose the backend technology stack.add_headeradds headers to the client response; always use thealwaysparameter so it applies to all status codes including errors.- The
add_headerinheritance trap: if alocationdefines its ownadd_header, it doesn’t inherit from the parent — use a snippet (include snippets/security-headers.conf) for consistency.- Separate security headers (X-Frame-Options, HSTS, etc.), proxy headers (X-Real-IP, X-Forwarded-For), and CORS headers into separate snippets for easier management.