Mutual TLS at HAProxy means the load balancer presents its server certificate and also requires the client to present a certificate signed by a trusted CA. The pattern is common for service-to-service auth, admin consoles behind cert-based access, and API gateways for partner traffic. This article covers the configuration, the request-side verification options, how to forward the client identity to backends, and the chain-of-trust mistakes that bite people.
How to verify
For an existing mTLS frontend, the right curl reveals whether HAProxy rejects unauthenticated clients and accepts authenticated ones:
curl -v https://api.example.com/ 2>&1 | grep -iE 'ssl|400|alert'
curl -v --cert client.pem --key client.key https://api.example.com/ 2>&1 | head -30
openssl s_client -connect api.example.com:443 -cert client.pem -key client.key < /dev/null
sudo journalctl -u haproxy -n 100 --no-pager | grep -i 'verify\|client.*cert'
The unauthenticated curl should fail with alert handshake failure or HTTP 400/495. The authenticated one should succeed. The journalctl line shows verify failures with the reason code — expired, no issuer match, CRL revoked.
What’s happening
HAProxy’s mTLS is driven by three bind directives:
ca-file <path>— the CA bundle HAProxy trusts to sign client certs.verify {none|optional|required}— whether to demand a client cert, allow one, or ignore.crl-file <path>— optional revocation list.
With verify required, HAProxy aborts the handshake if the client does not present a cert or presents one not signed by ca-file. With verify optional, the connection proceeds and HAProxy exposes the result via the ssl_c_verify and ssl_c_used fetches — your ACL decides what to do.
The client identity, once verified, is in fetches like ssl_c_s_dn (subject DN), ssl_c_i_dn (issuer DN), ssl_c_sha1, ssl_c_notafter. You forward these to the backend as HTTP headers — the backend trusts the headers because no untrusted hop sits between HAProxy and the app.
The CA file is a chain of trust, not a single cert. If your issuing CA has an intermediate, that intermediate plus the root must both be in ca-file, or HAProxy cannot validate a chain that ends at the intermediate.
The procedure
-
Build the CA bundle. From your PKI, concatenate every CA cert that signs valid clients:
sudo mkdir -p /etc/haproxy/ca sudo cat intermediate.pem root.pem > /etc/haproxy/ca/clients-ca.pem sudo chmod 644 /etc/haproxy/ca/clients-ca.pem sudo chown root:haproxy /etc/haproxy/ca/clients-ca.pem openssl crl2pkcs7 -nocrl -certfile /etc/haproxy/ca/clients-ca.pem | openssl pkcs7 -print_certs -nooutThe
crl2pkcs7line enumerates every cert in the bundle — useful to confirm the chain is complete. -
Require a verified client cert. The minimal frontend:
frontend fe_api bind *:443 ssl crt /etc/haproxy/certs/api.example.com.pem ca-file /etc/haproxy/ca/clients-ca.pem verify required default_backend be_apiWith
verify required, the TLS handshake fails for anyone without a valid client cert — the user sees an opaque browser error, the API client sees a TLS alert. There is no friendly error page. -
Soft mTLS with an ACL. When you want to allow uncerted clients on
/public/but require certs on/api/:frontend fe_mixed bind *:443 ssl crt /etc/haproxy/certs/api.example.com.pem ca-file /etc/haproxy/ca/clients-ca.pem verify optional acl is_api path_beg /api/ acl has_cert ssl_c_used acl cert_ok ssl_c_verify 0 http-request deny status 403 if is_api !has_cert http-request deny status 403 if is_api has_cert !cert_ok default_backend be_appssl_c_verifyis 0 on success; any other value is a failure code (1 = self-signed, 7 = expired, 23 = revoked, etc.). -
Forward client identity to the backend. The backend needs to know who it is talking to. Insert headers:
frontend fe_api bind *:443 ssl crt /etc/haproxy/certs/api.example.com.pem ca-file /etc/haproxy/ca/clients-ca.pem verify required http-request set-header X-Client-DN %{+Q}[ssl_c_s_dn] http-request set-header X-Client-SHA1 %[ssl_c_sha1,hex] http-request set-header X-Client-Verify %[ssl_c_verify] http-request del-header X-Client-DN if !{ ssl_c_used } default_backend be_apiThe
del-headerline is the safety: if a client somehow reaches this frontend without TLS, any spoofedX-Client-*they sent gets stripped. -
Revocation via CRL. Add a CRL file that HAProxy checks on every handshake:
frontend fe_api bind *:443 ssl crt /etc/haproxy/certs/api.example.com.pem ca-file /etc/haproxy/ca/clients-ca.pem crl-file /etc/haproxy/ca/clients.crl verify requiredRefresh the CRL on a timer (cron, every 4 hours). HAProxy reads the CRL at startup and on reload; runtime API has
set ssl crlfor hot updates. -
OCSP-based client cert checks. HAProxy 2.4+ supports OCSP responder checks for client certs via
ca-verify-fileandverifyrules. This is operationally heavier than CRL — every handshake makes an OCSP request — so we usually stick with CRL and a short refresh cadence. -
Test, reload, observe. Use a real client cert and an
openssl s_clientto confirm the handshake. Then check the backend access log for the forwarded headers — ifX-Client-DNis empty, something is wrong with the chain.
Common pitfalls
- The CA file must contain the full chain up to the root. If you put only the intermediate, HAProxy cannot validate a client cert whose chain ends there.
openssl verify -CAfile clients-ca.pem -untrusted intermediate.pem client.pemconfirms locally. verify requiredwith aca-filethat is unreadable by the haproxy user fails silently — the listener starts but rejects every handshake. Check file ownership and permissions.- CRL files have an expiry. If your CRL expires and you do not refresh, HAProxy 2.6+ rejects handshakes by default (CRL_check_all) — set up a cron that re-fetches the CRL well before its
nextUpdatefield. ssl_c_s_dnis the subject DN. If your PKI uses CN-only and not SAN, your backend should NOT trust the CN alone for authorization decisions — multiple CAs can issue the same CN. Pin on the cert fingerprint (ssl_c_sha1) or the issuer+serial combo.- Backend trust of forwarded headers is the security boundary. If anything else can route requests directly to the backend (a debug port, an internal LB without mTLS), the backend trusts headers it should not. Lock backends to HAProxy’s source IP or run mTLS end-to-end.
Stack Harbor stands up mTLS edges for clients running service-to-service APIs, partner integrations, and admin consoles — with CA rotation, CRL refresh, client cert issuance via an internal PKI, and backends that trust headers only from the HAProxy fleet. We document the chain of trust and verify it quarterly. This is what we run as part of Managed Operations for security-sensitive clients.