Client IP

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.

· 16 min read · How we verify this

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 0A and 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#

Propertyv1v2
EncodingASCII line, CRLF terminatedBinary, fixed 16-byte prologue
Maximum size107 bytes including CRLF16 bytes plus address block plus TLVs
DetectionStarts with PROXY Starts with the 12-byte signature
Address familiesTCP4, TCP6, UNKNOWNIPv4, IPv6, AF_UNIX, UNSPEC, each with stream or datagram
Extra metadataNoneTLVs: ALPN, authority (SNI), TLS details, CRC32C, cloud IDs
Parse costMust scan for CRLF, bounded readLength prefix, one read of a known size
Human readable in tcpdumpYesNo
Typical use todayLegacy receivers, quick debuggingEverything 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:

text
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 UNKNOWN line, 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\n is 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:

text
PROXY TCP4 192.0.2.10 198.51.100.5 56324 443\r\n

The 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:

text
0D 0A 0D 0A 00 0D 0A 51 55 49 54 0A

The 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:

OffsetSizeFieldValues
012SignatureThe constant above
121Version and commandHigh nibble 0x2 = version 2. Low nibble 0x0 = LOCAL, 0x1 = PROXY. So 0x20 or 0x21
131Family and transportHigh nibble family, low nibble transport (see below)
142LengthBig-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.

ByteFamilyTransportAddress block size
0x00AF_UNSPECunspecified0 bytes
0x11AF_INETSTREAM (TCP)12 bytes
0x12AF_INETDGRAM (UDP)12 bytes
0x21AF_INET6STREAM (TCP)36 bytes
0x22AF_INET6DGRAM (UDP)36 bytes
0x31AF_UNIXSTREAM216 bytes
0x32AF_UNIXDGRAM216 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.

TypeNameValue
0x01PP2_TYPE_ALPNNegotiated upper-layer protocol, for example h2 or http/1.1
0x02PP2_TYPE_AUTHORITYThe host the client asked for, normally the TLS SNI value
0x03PP2_TYPE_CRC32C32-bit CRC32c of the whole header with this field zeroed
0x04PP2_TYPE_NOOPPadding, ignored by the receiver
0x05PP2_TYPE_UNIQUE_IDOpaque connection identifier, up to 128 bytes, for correlating logs
0x20PP2_TYPE_SSLClient flags byte, uint32 verify result, then sub-TLVs
0x21PP2_SUBTYPE_SSL_VERSIONTLS version string, for example TLSv1.3
0x22PP2_SUBTYPE_SSL_CNCommon Name from the client certificate subject
0x23PP2_SUBTYPE_SSL_CIPHERNegotiated cipher suite name
0x24PP2_SUBTYPE_SSL_SIG_ALGSignature algorithm of the client certificate
0x25PP2_SUBTYPE_SSL_KEY_ALGKey algorithm of the client certificate
0x30PP2_TYPE_NETNSNetwork namespace name, US-ASCII
0xE0-0xEFVendor/application rangeReserved 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:

text
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#

SoftwareSendReceiveConfiguration
HAProxyv1 and v2v1 and v2server ... send-proxy / send-proxy-v2, bind ... accept-proxy
nginx (http)nov1 and v2listen 443 ssl proxy_protocol; (since 1.5.12)
nginx (stream)v1v1 and v2proxy_protocol on; to send, listen ... proxy_protocol; to receive
Envoyv1 and v2v1 and v2envoy.filters.listener.proxy_protocol to receive, the upstream proxy protocol transport socket to send
Traefikv2 (TCP services)v1 and v2entryPoints.x.proxyProtocol.trustedIPs, server proxyProtocol.version
Caddyv1 and v2v1 and v2listener_wrappers { proxy_protocol } to receive; transport http { proxy_protocol v2 } on reverse_proxy to send
Varnishnov1 and v2varnishd -a :80,PROXY
AWS Network Load Balancerv2 onlynot applicabletarget group attribute proxy_protocol_v2.enabled, default disabled
AWS Application Load Balancernonot applicableALB terminates HTTP and uses XFF instead
Azure Private Link Servicev2 onlynot applicableenable TCP Proxy v2 on the service; TLV 0xEE carries the link ID
stunnelv1v1protocol = proxy on the client side to send
Postfixnov1 and v2 (v2 since Postfix 3.5)smtpd_upstream_proxy_protocol = haproxy, postscreen_upstream_proxy_protocol = haproxy
Redis (open source)nonoNo listener option exists; the client address is lost behind an L4 balancer
PostgreSQL / PgBouncernonoNo 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:

nginx
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:

  1. 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.
  2. 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-proxy on 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:

haproxy
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_app

Connections 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:

text
broken header: "<garbled bytes>" while reading PROXY protocol,
client: 203.0.113.9, server: 0.0.0.0:443

The 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.

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.

  1. The PROXY protocol specification (HAProxy)
  2. nginx ngx_http_core_module listen directive
  3. nginx ngx_stream_proxy_module proxy_protocol
  4. Envoy Proxy Protocol listener filter
  5. AWS Network Load Balancer target group attributes
  6. Azure Private Link service overview (TCP Proxy v2)
  7. Postfix postconf.5 smtpd_upstream_proxy_protocol
  8. 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.

More in client ip and forwarding headers#