Skip to content

Behind a reverse proxy

A production topology runs h2corn behind a reverse proxy that handles browser-facing TLS:

browser/client  →  reverse proxy (TLS edge)  →  h2corn (h2c)

The proxy takes care of ALPN negotiation, TLS termination, and public-edge hardening. h2corn runs the application side of the connection on h2c — cleartext HTTP/2 over TCP or a Unix socket inside the trust boundary, established by prior knowledge rather than an Upgrade handshake.

flowchart LR
  accTitle: h2corn reverse proxy protocol and trust topology
  accDescr: A client reaches a TLS-terminating reverse proxy over HTTPS. The proxy reaches h2corn over cleartext HTTP/2 prior knowledge inside a private trust boundary. Only the proxy peer is allowed to supply forwarding identity headers.

  client["client / browser<br>public network"]
  subgraph boundary["private trust boundary"]
    direction LR
    proxy["reverse proxy<br>TLS edge / ALPN"]
    app["h2corn<br>ASGI application"]
    proxy -- "h2c" --> app
  end
  client -- "HTTPS" --> proxy
The edge terminates client TLS. The private hop uses h2c, and forwarding identity is trusted only from an allowlisted proxy peer.

The h2c hop avoids the HTTP/1.1 downgrade surface associated with request smuggling and HTTP/2 downgrading. A proxy with h2c upstream support keeps this boundary on HTTP/2. The edge still requires TLS, the cleartext hop requires peer isolation, and the application remains responsible for request validation.

A separate proxy is optional: h2corn can terminate TLS itself; see Direct TLS.

Compatibility boundaries

ProxyRequired capabilityLocal syntax check
nginx1.29.4+ HTTP/2 upstream support and ngx_http_v2_modulenginx -V, nginx -t
CaddyA supported reverse_proxy release with documented h2c transportcaddy validate
HAProxyA supported release with proto h2 and PROXY protocol v2haproxy -c

Proxy headers and PROXY protocol

h2corn accepts two kinds of trust hop metadata, both opt-in and gated by --forwarded-allow-ips:

  • --proxy-headers enables standard Forwarded and X-Forwarded-* processing for peers in --forwarded-allow-ips. By default it interprets X-Forwarded-For and X-Forwarded-Proto. Use --forwarded-fields for,proto,host,port,prefix to select additional X-Forwarded-* facts, or --forwarded-fields forwarded to select the RFC 7239 dialect. The two dialects cannot be combined.
  • --proxy-protocol v1|v2 parses HAProxy's PROXY protocol on inbound connections. It carries transport-level peer information on the connection itself for original source IP metrics or per-IP limits.

Proxy headers carry request metadata. Add PROXY protocol when the upstream is explicitly configured to send it.

h2corn reads a forwarding header from the right, skipping hops that match --forwarded-allow-ips and taking the first address outside that set. Client- prepended values sit to the left of the proxy values and are ignored. A proxy that appends rather than replaces — nginx's proxy_add_x_forwarded_for, HAProxy's option forwardfor, Caddy's default — matches the parser's expected append order.

nginx and Caddy emit X-Forwarded-* natively; HAProxy can send Forwarded with option forwarded. The selected fields are applied only for a peer in --forwarded-allow-ips. With --proxy-headers enabled, h2corn removes every recognized forwarding header that is not selected or does not come from a trusted peer before building scope["headers"]. Selected, consumed headers remain visible to the application.

Removal covers the underscore spellings the HTTP field-name grammar permits, so X_Forwarded_For cannot slip past a check written for X-Forwarded-For. It also reaches an application that reads a forwarding header itself instead of through the scope — Django under USE_X_FORWARDED_HOST, or a proxy middleware in the stack. Naming the field in --forwarded-fields keeps it.

--proxy-headers with an empty --forwarded-fields, or an empty --forwarded-allow-ips, is rejected at startup. Each describes a trust boundary that can never act, which is what leaving --proxy-headers off already spells.

With --proxy-headers disabled, forwarded headers are not trusted.

nginx

example.com, the certificate paths, hello:app, and the loopback proxy peer are placeholders. Set them to the deployment's domain, certificate files, application import target, and proxy peer address.

nginx documents HTTP/2 upstream support from 1.29.4 onwards, where the changelog records "the ngx_http_proxy_module supports HTTP/2". Set proxy_http_version 2 and keep the upstream URL on http://, which makes that hop cleartext h2c. The deployed build must include ngx_http_v2_module. This module has carried security advisories since it shipped, so run a currently supported release and check that list against it.

NGINX describes each upstream connection as handling "one request at a time rather than interleaving multiple requests on a single connection". Connection reuse and concurrency depend on the deployed build; the h2c hop provides HPACK and unambiguous framing, while the browser-facing hop multiplexes normally.

WebSockets need their own HTTP/1.1 location

nginx performs the HTTP/1.1 Upgrade handshake and does not translate it into the RFC 8441 extended CONNECT that HTTP/2 requires (see WebSockets). An HTTP/2 upstream cannot carry this handshake. Route /ws over HTTP/1.1 and all other requests over h2c. HTTP/1 must remain enabled when nginx serves WebSockets. For WebSockets over a pure HTTP/2 upstream, use HAProxy.

nginx.conf (configuration template)
# HTTP/2 to the upstream needs nginx built with ngx_http_v2_module. The feature
# appeared in 1.29.4, and the module has carried security advisories since, so
# run a supported release and check nginx's advisory list against it.
# `proxy_pass http://` keeps the upstream hop cleartext h2c, so there is no
# second TLS handshake between proxy and application.

upstream h2corn_backend {
    server 127.0.0.1:8000;

    # Without this every request opens a fresh upstream connection, which is
    # the cost h2c is supposed to remove.
    keepalive 32;
}

server {
    listen 443 ssl;
    http2 on;

    server_name example.com;
    ssl_certificate     /etc/nginx/certs/example.crt;
    ssl_certificate_key /etc/nginx/certs/example.key;

    # WebSockets first: nginx still speaks the HTTP/1.1 Upgrade handshake and
    # does not translate it into the RFC 8441 extended CONNECT that HTTP/2
    # requires, so upgrade traffic must take its own HTTP/1.1 location. This
    # nginx topology requires h2corn HTTP/1 to remain enabled.
    location /ws {
        proxy_pass http://h2corn_backend;
        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-Forwarded-For   $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host  $host;
        proxy_set_header X-Forwarded-Port  $server_port;
        # h2corn consumes only its configured forwarding fields and strips
        # other recognized forwarding headers before they reach the app.
    }

    location / {
        proxy_pass http://h2corn_backend;
        proxy_http_version 2;

        # Without this nginx sends the upstream *group* name — here
        # `h2corn_backend` — as Host/`:authority`, which is what host-based
        # routing and TrustedHostMiddleware would then see.
        proxy_set_header Host $host;

        # h2corn consumes only its configured forwarding fields and strips the
        # other recognized forwarding headers before they reach the app.
        proxy_set_header X-Forwarded-For   $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host  $host;
        proxy_set_header X-Forwarded-Port  $server_port;
    }
}

Run h2corn with the proxy-facing settings:

h2corn hello:app \
  --bind 127.0.0.1:8000 \
  --proxy-headers \
  --forwarded-fields for,proto,host,port \
  --forwarded-allow-ips 127.0.0.1,::1

Caddy

Caddy documents native h2c upstream support with its reverse_proxy directive.

Caddy WebSockets

Route WebSocket upgrades over HTTP/1.1 and ordinary requests over h2c. Caddy's h2c transport does not provide a documented RFC 8441 translation for this path and can return 502 for an HTTP/2 Upgrade request, often logged as http2: invalid Upgrade request header. HTTP/1 must remain enabled for this split route. A pure HTTP/2 route needs a proxy whose deployed version provides the RFC 8441 translation.

Caddyfile (configuration template)
example.com {
    # This split route sends WebSocket upgrades over HTTP/1.1; keep h2corn
    # HTTP/1 enabled. Use --no-http1 only with a separately verified pure h2c
    # route whose proxy translates upgrades to RFC 8441 extended CONNECT.
    @websockets header Connection *Upgrade*
    reverse_proxy @websockets 127.0.0.1:8000 {
        # h2corn strips recognized forwarding headers it does not consume.
    }

    reverse_proxy h2c://127.0.0.1:8000 {
        # h2corn strips recognized forwarding headers it does not consume.
    }
}

Run h2corn with the proxy-facing settings:

h2corn hello:app \
  --bind 127.0.0.1:8000 \
  --proxy-headers \
  --forwarded-fields for,proto,host,port \
  --forwarded-allow-ips 127.0.0.1,::1,unix

HAProxy

HAProxy speaks HTTP/2 upstream with proto h2 and can layer PROXY protocol v2 on the same connection — see the HAProxy HTTP guide for the full directive set.

HAProxy's documented HTTP/2 support can translate a browser's HTTP/1.1 Upgrade handshake into the RFC 8441 extended CONNECT that h2corn accepts over h2c. --no-http1 is valid only when the deployed HAProxy version provides that translation.

haproxy.cfg (configuration template)
global
    log stdout format raw daemon

defaults
    mode http
    timeout connect 5s
    timeout client 30s
    timeout server 30s
    # An idle WebSocket has to outlive the keepalive that proves it is alive:
    # h2corn's first ping goes out at websocket_ping_interval (60s by default),
    # so a 30s idle timeout would close the connection before it ever pinged.
    timeout tunnel 300s

frontend public_https
    bind :443 ssl crt /etc/haproxy/certs/example.pem alpn h2,http/1.1

    # h2corn consumes only its configured forwarding fields and strips the
    # other recognized forwarding headers before they reach the app.
    http-request set-header X-Forwarded-For %[src]
    http-request set-header X-Forwarded-Proto %[ssl_fc,iif(https,http)]
    http-request set-header X-Forwarded-Host %[req.hdr(host)]
    http-request set-header X-Forwarded-Port %[dst_port]

    default_backend h2corn_backend

backend h2corn_backend
    server app1 127.0.0.1:8000 check proto h2 send-proxy-v2

Run h2corn with the proxy-facing settings:

h2corn hello:app \
  --bind 127.0.0.1:8000 \
  --proxy-protocol v2 \
  --proxy-headers \
  --forwarded-fields for,proto,host,port \
  --forwarded-allow-ips 127.0.0.1,::1,unix \
  --no-http1

Other proxies

Other proxies need the same h2c, forwarding-header, and WebSocket checks. If a proxy cannot speak h2c, HTTP/1.1 remains the alternative private-hop protocol and --no-http1 must be omitted.