Skip to content

HAProxy mTLS with client certificates

Require client certificates at the HAProxy edge — verify, ca-file, CRL handling, header forwarding to backends, and the production gotchas around chain trust.

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

  1. 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 -noout

    The crl2pkcs7 line enumerates every cert in the bundle — useful to confirm the chain is complete.

  2. 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_api

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

  3. 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_app

    ssl_c_verify is 0 on success; any other value is a failure code (1 = self-signed, 7 = expired, 23 = revoked, etc.).

  4. 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_api

    The del-header line is the safety: if a client somehow reaches this frontend without TLS, any spoofed X-Client-* they sent gets stripped.

  5. 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 required

    Refresh the CRL on a timer (cron, every 4 hours). HAProxy reads the CRL at startup and on reload; runtime API has set ssl crl for hot updates.

  6. OCSP-based client cert checks. HAProxy 2.4+ supports OCSP responder checks for client certs via ca-verify-file and verify rules. This is operationally heavier than CRL — every handshake makes an OCSP request — so we usually stick with CRL and a short refresh cadence.

  7. Test, reload, observe. Use a real client cert and an openssl s_client to confirm the handshake. Then check the backend access log for the forwarded headers — if X-Client-DN is 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.pem confirms locally.
  • verify required with a ca-file that 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 nextUpdate field.
  • ssl_c_s_dn is 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.