Every request to a self-hosted stack passes through a reverse proxy before it reaches an application, and that single choke point is one of the highest-leverage places to enforce security policy — or, if misconfigured, one of the easiest places to accidentally undo whatever policy the application tried to enforce itself. A proxy that forwards headers uncritically, trusts client-supplied X-Forwarded-For values, or layers HTTP Basic Auth on top of a route that also expects a Bearer token will produce failures that look like application bugs but are actually proxy misconfiguration.

This article works through the concrete configuration for Traefik and Nginx: response security headers, rate limiting, the specific interaction between HTTP Basic Auth and bearer tokens on API routes, hop-by-hop header stripping, trusting X-Forwarded-For only from known proxies, and baseline TLS settings. The examples assume a small self-hosted stack — Docker Compose, a handful of services behind one proxy — but the same principles scale to a larger deployment.

Security Response Headers

A handful of response headers meaningfully reduce the blast radius of common client-side vulnerabilities, and none of them require touching application code if you set them at the proxy.

HeaderPurposeTypical value
Strict-Transport-SecurityForces browsers to only ever connect over HTTPS for this host, closing SSL-stripping downgrade attacksmax-age=63072000; includeSubDomains; preload
Content-Security-PolicyRestricts which script/style/frame/connect sources a page may load, mitigating XSS impactdefault-src 'self'; script-src 'self' (tune per app)
X-Frame-Options / frame-ancestorsPrevents the page from being embedded in a hostile iframe (clickjacking)DENY or frame-ancestors 'none'
X-Content-Type-OptionsStops browsers from MIME-sniffing responses into an unintended content typenosniff
Referrer-PolicyLimits how much of the current URL leaks to third-party destinations via the Referer headerstrict-origin-when-cross-origin
# Traefik - security headers via a middleware, applied per router
http:
  middlewares:
    security-headers:
      headers:
        stsSeconds: 63072000
        stsIncludeSubdomains: true
        stsPreload: true
        contentTypeNosniff: true
        frameDeny: true
        referrerPolicy: "strict-origin-when-cross-origin"
        customResponseHeaders:
          Content-Security-Policy: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"

# docker-compose.yml labels attaching the middleware to a service's router
services:
  web:
    labels:
      - "traefik.http.routers.web.middlewares=security-headers@file"
# Nginx equivalent, in the server block
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" always;

The always flag in Nginx matters: without it, add_header is skipped on error responses (4xx/5xx), which is exactly when you still want X-Frame-Options and friends applied.

Rate Limiting at the Proxy

Application-level rate limiting is good practice, but putting a first line of defense at the proxy means abusive traffic gets dropped before it reaches your app’s connection pool, database, or CPU-bound request handlers at all.

# Traefik - rate limit middleware, per source IP
http:
  middlewares:
    api-ratelimit:
      rateLimit:
        average: 20     # sustained requests per second
        burst: 40        # short burst allowance above average
        period: 1s
        sourceCriterion:
          ipStrategy:
            depth: 1      # see the X-Forwarded-For section below for why this matters

The Nginx equivalent uses a rate-limit zone plus a directive on the location block:

http {
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=20r/s;

    server {
        location /api/ {
            limit_req zone=api_limit burst=40 nodelay;
            limit_req_status 429;
            proxy_pass http://app_upstream;
        }
    }
}

Set limit_req_status 429 explicitly — Nginx defaults to 503 for rate-limited requests, which is semantically wrong (503 implies the service is unavailable, not that this client is being throttled) and confuses monitoring that alerts on 503 rates as an outage signal.

Basic Auth and Bearer Tokens Cannot Coexist on One Route

This is a pitfall that looks like a bug in the application every time it happens, and it isn’t: it’s a single-header collision at the HTTP protocol level. Both HTTP Basic Auth and Bearer token auth (used by most JWT/OAuth-based APIs) are carried in the exact same Authorization header.

Both are literally the same header name on the wire — Authorization: Basic dXNlcjpwYXNz and Authorization: Bearer eyJhbGciOiJSUzI1NiIs... are two values competing for one slot. There is only one Authorization header per request. If a proxy-level Basic Auth middleware is applied to a route, and the client (or the frontend JavaScript making the request) also sets Authorization: Bearer <token> for the API’s own auth scheme, only one of them survives on the wire — and depending on ordering, the proxy’s Basic Auth check runs before the request ever reaches the application, either stripping the bearer token entirely or rejecting the request with a 401 before the app-level auth logic gets a chance to run at all.

The practical symptom: an API behind a proxy-level access-code gate (common for staging environments or beta access walls) returns 401 for every request from a frontend that also sends its own bearer token, even though the bearer token is valid — because the browser silently drops or overwrites credentials depending on how it reconciles the two auth challenges, and Traefik/Nginx answers with its own 401 before your application logic runs.

# WRONG: Basic Auth gate applied to a route that also expects Bearer tokens
http:
  routers:
    api-wrong:
      rule: "Host(`api.example.com`)"
      middlewares:
        - basic-auth-gate   # collides with the app's own Bearer auth
      service: api-service

# RIGHT: Basic Auth gate scoped to non-API paths only, and stripped before
# it can reach any backend (e.g. S3-compatible storage) that parses
# Authorization as something other than Basic/Bearer credentials
http:
  routers:
    marketing-site:
      rule: "Host(`beta.example.com`) && !PathPrefix(`/api`)"
      middlewares:
        - basic-auth-gate
      service: frontend-service
    api:
      rule: "Host(`beta.example.com`) && PathPrefix(`/api`)"
      # no basic-auth-gate here - the API's own Bearer auth handles authorization
      service: api-service
  middlewares:
    basic-auth-gate:
      basicAuth:
        usersFile: "/run/secrets/htpasswd"
        removeHeader: true   # strip Authorization once the gate passes

If you need an access-code wall in front of an entire beta environment and an API with its own bearer-token auth, split the routing so the gate only applies to non-API paths, or move the gate to a mechanism that doesn’t use the Authorization header at all — a signed cookie set after an access-code check, for instance, checked by a proxy middleware that reads the cookie rather than Authorization.

The same collision shows up against S3-compatible object storage (MinIO, AWS S3): those services parse Authorization as a request signature, not a bearer token or Basic credential, so a proxy that injects Basic Auth in front of them produces a confusing 400 rather than a 401. If you must layer Basic Auth in front of an S3-compatible backend for some other reason, strip the header before it reaches the backend rather than trying to make both schemes coexist, as removeHeader: true does above.

Stripping Hop-by-Hop Headers

RFC 7230 defines a set of headers that apply only to a single connection hop, not end-to-end, and a well-behaved proxy should not forward them unchanged to the next hop: Connection, Keep-Alive, Transfer-Encoding, TE, Trailer, Upgrade, Proxy-Authorization, Proxy-Authenticate. Both Traefik and Nginx handle most of this automatically for standard proxying, but custom configurations that manually forward headers (common when building a WebSocket upgrade path, or a custom auth-forwarding middleware) can accidentally leak them through.

# Nginx - explicit, safe header handling for a proxied location, including WebSocket upgrade
location /ws/ {
    proxy_pass http://app_upstream;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    # Do not blindly pass through client-supplied hop-by-hop or proxy auth headers
    proxy_set_header Proxy-Authorization "";
}

Trusting X-Forwarded-For Only From Known Proxies

X-Forwarded-For is meant to record the client’s real IP as a request passes through one or more proxies, but it’s just a header — any client can set it directly, and if your application or rate limiter trusts it blindly, an attacker can spoof any source IP they like and bypass IP-based rate limiting or IP allowlists entirely.

An attacker connecting directly to your edge can simply send X-Forwarded-For: 10.0.0.1 themselves — there’s no proxy involved, and nothing stops the header value from being anything they choose. The fix is to only trust X-Forwarded-For (or append to it) from proxies you control, and to configure your edge proxy to overwrite, not append to, the header when the immediate connection isn’t from a known upstream.

# Nginx - only trust X-Forwarded-For from Cloudflare's published IP ranges
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
# ... (remaining Cloudflare ranges)
real_ip_header CF-Connecting-IP;   # Cloudflare's own header is more reliable than XFF here
real_ip_recursive on;

Traefik expresses the same idea with a depth setting on rate-limiting and IP-allowlist middlewares: depth: 1 means “trust the first IP counting from the right of X-Forwarded-For, assuming exactly one trusted proxy (e.g. Cloudflare) sits in front of Traefik.” Whichever mechanism you use, depth (or Nginx’s set_real_ip_from + real_ip_recursive) tells the proxy how many trusted hops to count back through the X-Forwarded-For chain, so a client can prepend as many fake IPs as they want to the header — the proxy still extracts the correct one from the position it knows to be its own trusted upstream, not whatever the client claims.

Baseline TLS Settings

# Nginx - a reasonable modern TLS baseline
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;   # TLS 1.3 negotiates cipher order client-side; this is fine
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;         # session tickets can undermine forward secrecy if key rotation is poor

Traefik expresses the same baseline through a named TLS options set (tls.options.default.minVersion: VersionTLS12, an equivalent cipherSuites list, and sniStrict: true to reject connections that don’t present a matching SNI hostname at all) applied globally or per router. Disable TLS 1.0/1.1 entirely — they carry known weaknesses and virtually no client still requires them — and periodically re-check your configuration against a current scanner (Qualys SSL Labs, testssl.sh) since acceptable cipher and protocol baselines shift over time as new weaknesses are found.

Key Takeaways

  • Set security response headers (HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy) at the proxy layer so every backend inherits them without code changes, and use always in Nginx so they apply on error responses too.
  • Rate-limit at the proxy as a first line of defense, and return 429 explicitly — Nginx’s default 503 for throttled requests confuses uptime monitoring.
  • HTTP Basic Auth and Bearer tokens share the same Authorization header and cannot both apply to one route; scope a Basic Auth gate to non-API paths, or move it off the Authorization header entirely (a signed cookie) when an API on the same host needs its own bearer auth.
  • Strip the Authorization header before it reaches backends that parse it as something else entirely, such as S3-compatible object storage.
  • Don’t manually forward hop-by-hop headers (Connection, Upgrade, Proxy-Authorization, etc.) in custom proxy configs; let the proxy’s standard handling manage them, especially around WebSocket upgrades.
  • Only trust X-Forwarded-For from known upstream proxies (set_real_ip_from in Nginx, ipStrategy.depth in Traefik) — otherwise a client can spoof their source IP and bypass IP-based rate limits or allowlists.
  • Restrict TLS to 1.2/1.3 with modern cipher suites, disable session tickets unless key rotation is solid, and re-validate the configuration periodically against a current scanner.