Proxy Protocol (HAProxy): Preserve Client IP Through Load Balancers

Proxy Protocol (HAProxy): Preserve Client IP Through Load Balancers

The PROXY protocol is a networking standard developed by HAProxy Technologies that solves a critical problem: when traffic passes through load balancers and reverse proxies, the original client IP address is lost. The PROXY protocol adds a small header to each connection containing the original source and destination IP addresses, allowing backend servers to see the real client IP even through multiple proxy layers.

The Problem

When a load balancer or reverse proxy sits between clients and backend servers, the backend only sees the proxy’s IP address — not the client’s:

Without PROXY Protocol:

Client (73.162.45.120)
    |
    v
Load Balancer (10.0.1.100)
    |
    v
Backend Server
    |  Sees connection from: 10.0.1.100 (load balancer IP)
    |  Client IP: UNKNOWN
    |  Problem: logging, rate limiting, geo-targeting all broken

Existing Solutions and Their Limitations

SolutionHow It WorksLimitation
X-Forwarded-For headerHTTP header with client IPHTTP only, can be spoofed
X-Real-IP headerSingle header with client IPHTTP only, not standardized
PROXY protocolTCP-level header prependedWorks for any TCP protocol

X-Forwarded-For only works for HTTP/HTTPS traffic and is easily spoofed by malicious clients. The PROXY protocol works at the TCP level, supporting any protocol (HTTP, SMTP, database connections, etc.).

How PROXY Protocol Works

Version 1 (Human-Readable)

PROXY protocol v1 header format:

PROXY TCP4 <source_ip> <dest_ip> <source_port> <dest_port>\r\n

Example:
PROXY TCP4 73.162.45.120 10.0.1.100 56789 443\r\n
<actual TCP data follows immediately>

Fields:
- PROXY        → Protocol identifier
- TCP4/TCP6    → IPv4 or IPv6
- 73.162.45.120→ Original client IP
- 10.0.1.100   → Original destination IP
- 56789        → Client source port
- 443          → Destination port
With PROXY Protocol v1:

Client (73.162.45.120:56789)
    |
    v
Load Balancer
    |  Prepends: "PROXY TCP4 73.162.45.120 10.0.1.100 56789 443\r\n"
    v
Backend Server
    |  Reads PROXY header first
    |  Knows client IP: 73.162.45.120
    |  Processes remaining data normally

Version 2 (Binary)

PROXY protocol v2 header format (binary, more efficient):

Byte 0-11:  Signature (\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A)
Byte 12:    Version (2) + Command (PROXY=1 or LOCAL=0)
Byte 13:    Address family + Transport protocol
Byte 14-15: Length of address data
Byte 16+:   Address data (source IP, dest IP, source port, dest port)
Optional:   TLV (Type-Length-Value) extensions

Extensions include:
- PP2_TYPE_ALPN         → TLS ALPN negotiated protocol
- PP2_TYPE_AUTHORITY    → SNI hostname
- PP2_TYPE_SSL          → TLS version, cipher, client cert info
- PP2_TYPE_UNIQUE_ID    → Unique connection identifier

HAProxy Configuration

Basic Setup

# haproxy.cfg

global
    log /dev/log local0
    maxconn 4096

defaults
    mode tcp
    timeout connect 5s
    timeout client  30s
    timeout server  30s

# Frontend — accept client connections
frontend web_frontend
    bind *:443 ssl crt /etc/ssl/certs/server.pem
    default_backend web_servers

# Backend — send to servers WITH proxy protocol
backend web_servers
    balance roundrobin
    server web1 10.0.2.10:8080 send-proxy-v2
    server web2 10.0.2.11:8080 send-proxy-v2
    server web3 10.0.2.12:8080 send-proxy-v2

Receiving PROXY Protocol

# HAProxy as a backend that receives PROXY protocol from another LB

frontend from_upstream_lb
    bind *:8080 accept-proxy
    # Now $src contains the ORIGINAL client IP, not the upstream LB
    default_backend application

Mixed Mode (Some Connections with PP, Some Without)

frontend mixed_frontend
    bind *:80
    bind *:8080 accept-proxy    # Only this port expects PROXY protocol

    # Use src for logging — will show real client IP when accept-proxy is used
    log-format "%ci:%cp -> %fi:%fp [%t] %ft %b/%s %Tq/%Tw/%Tc/%Tr/%Tt %ST %B %CC"

Nginx Configuration

Receiving PROXY Protocol

# /etc/nginx/nginx.conf

stream {
    server {
        listen 443 ssl proxy_protocol;
        ssl_certificate /etc/ssl/server.crt;
        ssl_certificate_key /etc/ssl/server.key;

        # $proxy_protocol_addr contains the real client IP
        proxy_pass backend_servers;
    }
}

http {
    # For HTTP backends receiving PROXY protocol
    server {
        listen 8080 proxy_protocol;

        # Set real IP from PROXY protocol
        set_real_ip_from 10.0.0.0/8;      # Trust the load balancer subnet
        real_ip_header proxy_protocol;

        # Now $remote_addr is the real client IP
        location / {
            proxy_pass http://app_server;
            proxy_set_header X-Real-IP $proxy_protocol_addr;
            proxy_set_header X-Forwarded-For $proxy_protocol_addr;
        }
    }
}

Sending PROXY Protocol

stream {
    upstream backend {
        server 10.0.2.10:8080;
        server 10.0.2.11:8080;
    }

    server {
        listen 443 ssl;
        proxy_pass backend;
        proxy_protocol on;  # Send PROXY protocol to backend
    }
}

AWS Integration

Application Load Balancer (ALB)

ALB does not support PROXY protocol — it uses X-Forwarded-For instead. Use NLB for PROXY protocol.

Network Load Balancer (NLB)

NLB Configuration:
1. Create target group
2. Enable "Proxy protocol v2" in target group attributes
3. Configure backend instances to accept PROXY protocol

AWS CLI:
aws elbv2 modify-target-group-attributes \
    --target-group-arn arn:aws:elasticloadbalancing:... \
    --attributes Key=proxy_protocol_v2.enabled,Value=true

ECS/Fargate with NLB

{
    "targetGroupAttributes": [
        {
            "key": "proxy_protocol_v2.enabled",
            "value": "true"
        }
    ]
}

Application-Level Handling

Python — Reading PROXY Protocol Header

import socket
import struct

def read_proxy_protocol_v1(conn):
    """Parse PROXY protocol v1 header from connection"""
    data = b""
    while b"\r\n" not in data:
        data += conn.recv(1)

    header = data.decode().strip()
    parts = header.split()

    if parts[0] != "PROXY":
        raise ValueError("Not a PROXY protocol header")

    return {
        "protocol": parts[1],       # TCP4 or TCP6
        "source_ip": parts[2],      # Client IP
        "dest_ip": parts[3],        # Original destination
        "source_port": int(parts[4]),
        "dest_port": int(parts[5]),
    }

def read_proxy_protocol_v2(conn):
    """Parse PROXY protocol v2 header"""
    signature = conn.recv(12)
    expected = b'\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A'

    if signature != expected:
        raise ValueError("Invalid PROXY protocol v2 signature")

    ver_cmd = struct.unpack('B', conn.recv(1))[0]
    fam_proto = struct.unpack('B', conn.recv(1))[0]
    length = struct.unpack('!H', conn.recv(2))[0]

    addr_data = conn.recv(length)

    # Parse IPv4 addresses
    if (fam_proto >> 4) == 1:  # AF_INET
        src_ip = socket.inet_ntoa(addr_data[0:4])
        dst_ip = socket.inet_ntoa(addr_data[4:8])
        src_port = struct.unpack('!H', addr_data[8:10])[0]
        dst_port = struct.unpack('!H', addr_data[10:12])[0]

        return {
            "source_ip": src_ip,
            "dest_ip": dst_ip,
            "source_port": src_port,
            "dest_port": dst_port,
        }

Go — PROXY Protocol Library

package main

import (
    "fmt"
    "net"
    proxyproto "github.com/pires/go-proxyproto"
)

func main() {
    listener, _ := net.Listen("tcp", ":8080")

    // Wrap listener to parse PROXY protocol
    proxyListener := &proxyproto.Listener{Listener: listener}

    for {
        conn, _ := proxyListener.Accept()

        // conn.RemoteAddr() now returns the REAL client IP
        // (from PROXY protocol header, not the load balancer IP)
        fmt.Printf("Real client IP: %s\n", conn.RemoteAddr())

        conn.Close()
    }
}

When to Use PROXY Protocol

ScenarioUse PROXY Protocol?Alternative
HTTP behind load balancerOptionalX-Forwarded-For works
HTTPS behind LB (TCP mode)YesCannot use XFF in TCP mode
Non-HTTP protocols (SMTP, DB)YesOnly option
Multi-layer proxy chainYesXFF can be spoofed
Cloud NLB to backendYesRecommended by AWS
CDN to originDependsMost CDNs use XFF

Frequently Asked Questions

What is the difference between PROXY protocol and X-Forwarded-For?

X-Forwarded-For is an HTTP header, so it only works with HTTP/HTTPS traffic. The PROXY protocol works at the TCP level, supporting any protocol (email, databases, custom protocols). X-Forwarded-For can also be spoofed by clients, while PROXY protocol headers are added by trusted infrastructure components and are harder to forge.

Can PROXY protocol be used with forward proxies?

The PROXY protocol is designed for reverse proxy/load balancer scenarios where you control both the proxy and the backend. It is not typically used with forward proxies (like web scraping proxies) because the target website does not expect or support PROXY protocol headers. Forward proxies use the standard HTTP proxy protocol or SOCKS5 instead.

Does PROXY protocol add latency?

The overhead is negligible — the v1 header is a single text line (under 100 bytes), and v2 is a compact binary header (16-52 bytes). The header is sent once per connection, not per request. The parsing time is microseconds. There is no measurable latency impact.

What happens if a backend receives PROXY protocol but is not configured for it?

The backend will interpret the PROXY protocol header as the beginning of the actual request data, causing parsing errors. For HTTP servers, the request will appear malformed and return a 400 Bad Request error. Always ensure both sender and receiver agree on whether PROXY protocol is enabled.

Is PROXY protocol v2 always better than v1?

v2 is more efficient (binary format, faster parsing) and supports extensions (TLS info, unique IDs). However, v1 is human-readable and easier to debug. For production systems, v2 is recommended. For debugging and development, v1 is convenient. Most modern software supports both versions. Learn more about proxy types in our reverse proxy guide.

Conclusion

The PROXY protocol is an essential infrastructure component for any deployment using load balancers or reverse proxies. It preserves the original client IP address at the TCP level, enabling accurate logging, rate limiting, and security policies regardless of how many proxy layers sit between clients and backends. Use v2 for production deployments, configure both sender and receiver consistently, and always restrict PROXY protocol acceptance to trusted sources.

For related topics, see our guides on reverse proxies and proxy authentication methods.


Related Reading

Scroll to Top