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" --> proxyThe 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¶
| Proxy | Required capability | Local syntax check |
|---|---|---|
| nginx | 1.29.4+ HTTP/2 upstream support and ngx_http_v2_module | nginx -V, nginx -t |
| Caddy | A supported reverse_proxy release with documented h2c transport | caddy validate |
| HAProxy | A supported release with proto h2 and PROXY protocol v2 | haproxy -c |
Proxy headers and PROXY protocol¶
h2corn accepts two kinds of trust hop metadata, both opt-in and gated by --forwarded-allow-ips:
--proxy-headersenables standardForwardedandX-Forwarded-*processing for peers in--forwarded-allow-ips. By default it interpretsX-Forwarded-ForandX-Forwarded-Proto. Use--forwarded-fieldsfor,proto,host,port,prefixto select additionalX-Forwarded-*facts, or--forwarded-fields forwardedto select the RFC 7239 dialect. The two dialects cannot be combined.--proxy-protocolv1|v2parses 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.
# 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.
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.
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.