The PROXY protocol (v1 and v2)
PROXY protocol v1 and v2 at byte level: the exact grammar, the TLV registry, an annotated hex dump, a send/receive support matrix, and the listener rule.
Key points
- The PROXY protocol prefixes a TCP connection with the original source and destination addresses, so an L4 proxy can preserve the client IP for protocols that have no header to put it in.
- v1 is a single CRLF-terminated ASCII line of at most 107 bytes; v2 is a binary block starting with the 12-byte signature
0D 0A 0D 0A 00 0D 0A 51 55 49 54 0Aand can carry TLVs. - A listener with the PROXY protocol enabled must never be reachable by traffic that lacks the header, because the header is trusted unconditionally and cannot be authenticated.
- v2 TLVs carry ALPN, SNI authority, TLS client certificate details and cloud identifiers such as the AWS VPC endpoint ID (
0xEA) and Azure Private Link ID (0xEE).
The PROXY protocol is a connection preamble, defined by HAProxy and implemented widely, that lets a layer 4 proxy tell its backend the address and port of the real client before any application bytes flow. The proxy writes a small header as the very first thing on the freshly accepted TCP connection; the backend consumes it, records the addresses, and then treats the remaining stream as the normal protocol. It exists because X-Forwarded-For only works when the proxy parses HTTP, and a TCP or TLS passthrough proxy does not.
v1 versus v2 at a glance#
| Property | v1 | v2 |
|---|---|---|
| Encoding | ASCII line, CRLF terminated | Binary, fixed 16-byte prologue |
| Maximum size | 107 bytes including CRLF | 16 bytes plus address block plus TLVs |
| Detection | Starts with PROXY | Starts with the 12-byte signature |
| Address families | TCP4, TCP6, UNKNOWN | IPv4, IPv6, AF_UNIX, UNSPEC, each with stream or datagram |
| Extra metadata | None | TLVs: ALPN, authority (SNI), TLS details, CRC32C, cloud IDs |
| Parse cost | Must scan for CRLF, bounded read | Length prefix, one read of a known size |
Human readable in tcpdump | Yes | No |
| Typical use today | Legacy receivers, quick debugging | Everything else |
The decision rule is short: send v2 unless the receiver only understands v1. v2 is cheaper to parse safely (the length field tells the receiver exactly how many bytes to consume, so there is no unbounded scan), and it is the only version that can carry the TLS and cloud-identity metadata that a passthrough edge otherwise destroys. Keep v1 in mind as a debugging tool: switching a listener to v1 for ten minutes makes the handshake legible in a packet capture.
Why the protocol exists at all#
When a reverse proxy terminates HTTP, it can append the client address to a request header. That option disappears in three common situations:
- TLS passthrough. The proxy routes on SNI and never decrypts, so there is no place to write a header. See TLS termination, passthrough and re-encryption for when this is the right design.
- Non-HTTP protocols. SMTP, IMAP, MQTT, Redis, PostgreSQL and raw TCP services have no general-purpose extension point at the start of a session.
- Address translation in the path. A load balancer that rewrites the destination address (and often the source) leaves the backend seeing only the balancer's address in
getpeername().
The PROXY protocol solves all three the same way: put the layer 3/4 facts in band, once, before anything else. The header is not authenticated and not encrypted. Its trustworthiness comes entirely from the fact that only the proxy can reach the port.
The v1 text format#
The v1 header is one line. The grammar, in the terms the specification uses:
PROXY SP <proto> SP <src-addr> SP <dst-addr> SP <src-port> SP <dst-port> CRLF
proto := "TCP4" | "TCP6" | "UNKNOWN"Rules a correct implementation has to honour:
- The line ends with CRLF. A bare LF is not acceptable and a receiver must not accept it.
- Total length is at most 107 bytes including the CRLF. A TCP4 line cannot exceed 56 bytes and a TCP6 line 104 bytes; the 107 byte bound comes from the worst-case
UNKNOWNline, whose longer protocol word leaves room for two full-length IPv6 addresses. - Addresses are in canonical text form. No leading zeros in octets, no leading zeros in port numbers, ports are decimal in the range 0 to 65535.
- With
UNKNOWN, the sender may omit everything after the protocol word.PROXY UNKNOWN\r\nis a complete, valid header, and the receiver must fall back to the real socket addresses. - The sender must emit the header in a single write so it is not split across segments in a way that a naive receiver mishandles.
A real v1 header looks like this:
PROXY TCP4 192.0.2.10 198.51.100.5 56324 443\r\nThe 107 byte ceiling is why v1 receivers can safely read a bounded number of bytes and give up: any connection that has not produced a CRLF within 107 bytes is not speaking v1, so the receiver can close instead of buffering indefinitely.
The v2 binary format#
Every v2 header starts with a 12-byte signature chosen so that it cannot be confused with a plausible first line of any text protocol:
0D 0A 0D 0A 00 0D 0A 51 55 49 54 0AThe embedded NUL byte terminates any C string comparison, and the trailing QUIT\n is what a text protocol would see if it somehow parsed the rest. After the signature come four more fixed bytes:
| Offset | Size | Field | Values |
|---|---|---|---|
| 0 | 12 | Signature | The constant above |
| 12 | 1 | Version and command | High nibble 0x2 = version 2. Low nibble 0x0 = LOCAL, 0x1 = PROXY. So 0x20 or 0x21 |
| 13 | 1 | Family and transport | High nibble family, low nibble transport (see below) |
| 14 | 2 | Length | Big-endian uint16: number of bytes following this field |
The length field is the load-bearing part of the design. A receiver reads 16 bytes, learns the remaining length, reads exactly that many, and is done. It must skip bytes it does not understand rather than failing, which is what makes TLV extension safe.
Command. PROXY (0x21) means the header describes a real client connection. LOCAL (0x20) means the connection originates from the proxy itself, typically a health check, and the receiver must ignore the address block and use the real socket addresses. Health checkers that use LOCAL are the reason a backend should not log a client IP of 0.0.0.0 and panic.
Family and transport byte.
| Byte | Family | Transport | Address block size |
|---|---|---|---|
0x00 | AF_UNSPEC | unspecified | 0 bytes |
0x11 | AF_INET | STREAM (TCP) | 12 bytes |
0x12 | AF_INET | DGRAM (UDP) | 12 bytes |
0x21 | AF_INET6 | STREAM (TCP) | 36 bytes |
0x22 | AF_INET6 | DGRAM (UDP) | 36 bytes |
0x31 | AF_UNIX | STREAM | 216 bytes |
0x32 | AF_UNIX | DGRAM | 216 bytes |
The address block is source address, destination address, source port, destination port, in that order, all big-endian. IPv4 gives 4 + 4 + 2 + 2 = 12. IPv6 gives 16 + 16 + 2 + 2 = 36. AF_UNIX carries two 108-byte NUL-padded paths and no ports. Anything left over after the address block is TLVs.
The TLV registry#
Each TLV is a one-byte type, a two-byte big-endian length, and that many value bytes.
| Type | Name | Value |
|---|---|---|
0x01 | PP2_TYPE_ALPN | Negotiated upper-layer protocol, for example h2 or http/1.1 |
0x02 | PP2_TYPE_AUTHORITY | The host the client asked for, normally the TLS SNI value |
0x03 | PP2_TYPE_CRC32C | 32-bit CRC32c of the whole header with this field zeroed |
0x04 | PP2_TYPE_NOOP | Padding, ignored by the receiver |
0x05 | PP2_TYPE_UNIQUE_ID | Opaque connection identifier, up to 128 bytes, for correlating logs |
0x20 | PP2_TYPE_SSL | Client flags byte, uint32 verify result, then sub-TLVs |
0x21 | PP2_SUBTYPE_SSL_VERSION | TLS version string, for example TLSv1.3 |
0x22 | PP2_SUBTYPE_SSL_CN | Common Name from the client certificate subject |
0x23 | PP2_SUBTYPE_SSL_CIPHER | Negotiated cipher suite name |
0x24 | PP2_SUBTYPE_SSL_SIG_ALG | Signature algorithm of the client certificate |
0x25 | PP2_SUBTYPE_SSL_KEY_ALG | Key algorithm of the client certificate |
0x30 | PP2_TYPE_NETNS | Network namespace name, US-ASCII |
0xE0-0xEF | Vendor/application range | Reserved for application-specific data |
Inside 0x20, the client byte is a bitfield: 0x01 the client connected over TLS, 0x02 the client presented a certificate on this connection, 0x04 the client presented a certificate at some point in the session. The verify field is zero only when a presented certificate verified successfully, so verify != 0 is the check that matters if you are carrying mutual TLS identity through a proxy.
Two cloud vendors use the custom range. AWS sends type 0xEA from Network Load Balancers and PrivateLink endpoint services, with a subtype byte introducing the VPC endpoint ID, so a service provider can attribute a connection to a specific consumer VPC. Azure Private Link Service sends type 0xEE carrying the numeric link identifier, which matches the linkIdentifier property on the private endpoint connection. In both cases the source IP alone is not a useful identity (it is a shared, often overlapping RFC 1918 address), and the TLV is the only way to tell tenants apart.
0x03 CRC32C is worth enabling when the header traverses anything that might corrupt it, but note it protects against accidental corruption only. It uses the Castagnoli polynomial and is computed with the CRC field present and zeroed, so a sender must lay out the header first and patch the field afterwards.
An annotated v2 header#
The following 33 bytes are a complete v2 header for a TCP/IPv4 connection from 192.0.2.10:56324 to 198.51.100.5:443 with one ALPN TLV:
0000 0d 0a 0d 0a 00 0d 0a 51 signature, bytes 1-8
0008 55 49 54 0a signature, bytes 9-12 ("QUIT\n")
000c 21 version 2 (0x2_), command PROXY (0x_1)
000d 11 AF_INET (0x1_), STREAM/TCP (0x_1)
000e 00 11 length = 17 bytes follow
0010 c0 00 02 0a source address 192.0.2.10
0014 c6 33 64 05 destination address 198.51.100.5
0018 dc 04 source port 56324
001a 01 bb destination port 443
001c 01 TLV type 0x01 (PP2_TYPE_ALPN)
001d 00 02 TLV length 2
001f 68 32 TLV value "h2"Total on the wire is 16 + 17 = 33 bytes, and the receiver knows that after reading offset 0x0f it needs exactly 17 more. Paste bytes like these into the PROXY protocol decoder to check a capture field by field.
Who can send and who can receive#
| Software | Send | Receive | Configuration |
|---|---|---|---|
| HAProxy | v1 and v2 | v1 and v2 | server ... send-proxy / send-proxy-v2, bind ... accept-proxy |
| nginx (http) | no | v1 and v2 | listen 443 ssl proxy_protocol; (since 1.5.12) |
| nginx (stream) | v1 | v1 and v2 | proxy_protocol on; to send, listen ... proxy_protocol; to receive |
| Envoy | v1 and v2 | v1 and v2 | envoy.filters.listener.proxy_protocol to receive, the upstream proxy protocol transport socket to send |
| Traefik | v2 (TCP services) | v1 and v2 | entryPoints.x.proxyProtocol.trustedIPs, server proxyProtocol.version |
| Caddy | v1 and v2 | v1 and v2 | listener_wrappers { proxy_protocol } to receive; transport http { proxy_protocol v2 } on reverse_proxy to send |
| Varnish | no | v1 and v2 | varnishd -a :80,PROXY |
| AWS Network Load Balancer | v2 only | not applicable | target group attribute proxy_protocol_v2.enabled, default disabled |
| AWS Application Load Balancer | no | not applicable | ALB terminates HTTP and uses XFF instead |
| Azure Private Link Service | v2 only | not applicable | enable TCP Proxy v2 on the service; TLV 0xEE carries the link ID |
| stunnel | v1 | v1 | protocol = proxy on the client side to send |
| Postfix | no | v1 and v2 (v2 since Postfix 3.5) | smtpd_upstream_proxy_protocol = haproxy, postscreen_upstream_proxy_protocol = haproxy |
| Redis (open source) | no | no | No listener option exists; the client address is lost behind an L4 balancer |
| PostgreSQL / PgBouncer | no | no | No PROXY protocol listener option; use transparent proxying or application-level identity |
Two entries in that table cause most of the surprises. nginx can receive the PROXY protocol on any listener but open source nginx can only send it from the stream module, not from ngx_http_proxy_module. If your topology is nginx to nginx over HTTP and you expected proxy_protocol on; in a location block, it does not exist; use proxy_set_header X-Forwarded-For there instead. And AWS NLB sends v2 while classic ELB spoke v1, so a backend written against the older format needs updating during a migration.
To make the addresses actually appear in nginx logs and variables, pair the listener with the realip module:
server {
listen 443 ssl proxy_protocol;
set_real_ip_from 10.0.0.0/8;
real_ip_header proxy_protocol;
# $proxy_protocol_addr always holds the header value
# $realip_remote_addr always holds the true socket peer
log_format ppfmt '$realip_remote_addr -> $remote_addr "$request"';
access_log /var/log/nginx/access.log ppfmt;
}nginx 1.23.2 and later also exposes TLVs as $proxy_protocol_tlv_alpn, $proxy_protocol_tlv_authority and the raw $proxy_protocol_tlv_0xEE form used to read the Azure link identifier.
The rule that breaks deployments: the port must be closed to everyone else#
A listener with the PROXY protocol enabled is strictly incompatible with traffic that lacks the header. The specification is explicit that a receiver must not accept a connection without the header on such a listener, and implementations enforce that by dropping the connection. This has two consequences you have to design for:
- Firewall the port to the proxy's addresses only. A security group, a network policy, or a bind to a private interface. If the backend is reachable from the internet on its PROXY port, an attacker sets any client IP they want, defeating every control described in client IP spoofing through proxies.
- Fix your health checks. Any monitoring or load balancer health check that opens a raw TCP connection to a PROXY-enabled port fails. HAProxy has
check-send-proxyon the server line for exactly this. External blackbox probes need to be taught to emit a header or pointed at a separate plain listener.
HAProxy offers a middle path that is under-used. Instead of accept-proxy, which makes the header mandatory for everyone, you can require it only from known sources:
frontend fe_app
bind 0.0.0.0:443
tcp-request connection expect-proxy layer4 if { src 10.0.0.0/8 }
default_backend be_appConnections from 10.0.0.0/8 must present a header; everything else is handled normally. This is the configuration to use during a migration, because it lets you flip senders over one at a time instead of coordinating a simultaneous cutover. See HAProxy configuration for HTTP reverse proxying for where this sits relative to the rest of the frontend.
Failure modes#
Plain TLS arrives at a PROXY listener. The receiver reads a TLS ClientHello, which starts with 0x16 0x03, and finds neither PROXY nor the v2 signature. nginx logs:
broken header: "<garbled bytes>" while reading PROXY protocol,
client: 203.0.113.9, server: 0.0.0.0:443The client sees the connection reset during the handshake, typically curl: (35) OpenSSL SSL_connect: Connection reset by peer or an immediate EOF. Fix: either the sender is not configured to send, or something (a health checker, a scanner, a service mesh sidecar) is reaching the port directly.
A header arrives at a listener that does not expect one. The receiver parses PROXY TCP4 ... as an HTTP request line and rejects it. nginx logs client sent invalid method while reading client request line with the request text showing the PROXY line, and the client gets 400 Bad Request. With v2 the binary signature usually produces a plain connection reset instead. Fix: add proxy_protocol to the listen directive, or turn off sending.
v1 sender, v2-only receiver (or the reverse). Most receivers autodetect both, but a hand-rolled parser that only checks the 12-byte signature silently mangles v1 input. Symptom: the first 107 bytes of the client's real payload go missing, so a TLS handshake fails with a decode error rather than a connection error.
Truncated header across segments. A sender that writes the header in two write() calls, or a middlebox that splits the segment, breaks receivers that assume one read returns the whole header. Symptom: intermittent failures under load only. Fix: senders must write the header atomically; receivers must loop until they have the declared length.
LOCAL command misread. A health check arrives as 0x20 with a zero-length address block, and a backend that blindly reads 12 address bytes consumes the first 12 bytes of the application stream. Symptom: health checks pass but the first real request on a reused connection is corrupt.
Everything works, but the client IP is still the balancer's. The header arrived and was parsed, but nothing maps it onto the variable the application reads. In nginx you need set_real_ip_from plus real_ip_header proxy_protocol; in Envoy the listener filter populates the downstream remote address only if use_remote_address semantics downstream are set up correctly. This is the same trust-boundary wiring described in configuring trusted proxies.
Frequently asked questions#
Is the PROXY protocol encrypted or authenticated?#
No. It is cleartext with no signature, no timestamp and no nonce, and the optional CRC32C TLV only detects accidental corruption. Trust comes entirely from restricting who can connect to the listener, which is why a PROXY-enabled port must be firewalled to the proxy's addresses.
Should I use v1 or v2?#
Use v2 unless the receiver only understands v1. v2 has a length prefix that makes parsing bounded and unambiguous, and it is the only version that can carry TLVs such as ALPN, SNI authority, TLS client certificate fields and cloud endpoint identifiers. v1 remains useful for debugging because it is readable in a packet capture.
Can I run the PROXY protocol over UDP?#
The v2 family byte has DGRAM values (0x12 for IPv4, 0x22 for IPv6), so the format can describe a datagram association, but there is no framing for repeated datagrams and support is rare. In practice the protocol is used on TCP, and UDP client IP preservation is done with transparent proxying instead.
Does enabling the PROXY protocol on AWS NLB affect health checks?#
Enabling proxy_protocol_v2.enabled changes what targets receive on data connections, so any target listener you point health checks at must be prepared for it. The usual pattern is a separate health check port with a plain listener, which also avoids the situation where a target is marked healthy by a probe that never exercises the header parsing path.
Why does my TLS handshake fail after turning on proxy_protocol?#
Because a listener with proxy_protocol set treats the first bytes of the connection as a header, and a ClientHello is not one. Either the upstream sender is not actually sending the header, or something else can reach the port directly. Check the nginx error log for broken header ... while reading PROXY protocol and confirm the source address in that line.
How do I read the AWS VPC endpoint ID or Azure link ID from the header?#
Both arrive as v2 TLVs in the vendor range: AWS uses type 0xEA with a subtype byte before the endpoint ID, Azure uses 0xEE with the numeric link identifier. nginx 1.23.2 and later exposes arbitrary types as $proxy_protocol_tlv_0xEA and $proxy_protocol_tlv_0xEE; other stacks need the application to parse the TLV list after the address block.
What happens if the same connection carries both a PROXY header and X-Forwarded-For?#
They describe different layers and both can be legitimate: the header describes the TCP peer of the L4 hop, XFF describes the HTTP chain. Decide explicitly which one your application treats as authoritative, and make sure the L7 proxy does not append the L4 balancer's own address into XFF as though it were a client.
Can Redis or PostgreSQL accept a PROXY header?#
Neither open source Redis nor PostgreSQL (including PgBouncer) implements a PROXY protocol listener option, so an L4 balancer in front of them loses the client address. The workarounds are transparent proxying at the network layer, per-application credentials that identify the caller, or terminating in a proxy that does understand the protocol and logging there.
Primary sources#
Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.
- The PROXY protocol specification (HAProxy)
- nginx ngx_http_core_module listen directive
- nginx ngx_stream_proxy_module proxy_protocol
- Envoy Proxy Protocol listener filter
- AWS Network Load Balancer target group attributes
- Azure Private Link service overview (TCP Proxy v2)
- Postfix postconf.5 smtpd_upstream_proxy_protocol
- Varnish varnishd command line reference (-a PROXY)
Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.