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 --> BACKEND

What Gets Removed by Default #

HeaderReason for Removal
ConnectionHop-by-hop header — irrelevant for the next connection
Keep-AliveHop-by-hop header
UpgradeHop-by-hop header (needs to be re-added for WebSocket)
Headers with underscores _Considered invalid by default

What Gets Changed by Default #

HeaderOriginal ValueValue After Nginx
Hostexample.comlocalhost 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 Host and removes some headers (Connection, hop-by-hop) — the backend doesn’t automatically know the client’s real IP.
  • proxy_set_header Host $host is 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_header adds headers to the client response; always use the always parameter so it applies to all status codes including errors.
  • The add_header inheritance trap: if a location defines its own add_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.

← Previous: proxy_pass   Next: Buffering & Timeouts →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact