Restrict API Access With Client Certificates (mTLS)

An application programming interface (API) provides access to the features of a business application but with the visual elements stripped away. By using APIs, devices like tablets, self-service kiosks, point-of-sale terminals, and robotic sensors can connect to apps running on servers in a data center or in the cloud. Because they give access to the heart of your business applications, it should come as no surprise that there are some APIs that the general public should not have access to. Take, for example, an API for a warehouse management system or one for a retail point-of-sale system. In such cases, access should be locked down, allowing entry only to authenticated clients that have been preapproved.

Client certificate authentication is a reliable way to restrict API access and, in recent years, has grown in popularity, perhaps due to being restyled under a new name that better captures its use case: mutual TLS, or mTLS for short. The idea remains the same, however. You can store a digital certificate on a client, which allows the client to unlock access to an API. The server verifies the authenticity of the client’s certificate, and, meanwhile, the client can verify that the server is the expected provider of that API. Thus, the client and server verify one another. Authentication and trust are mutual between the two parties.

HAProxy, when placed in front of your servers, enables mTLS. In fact, it supports using client certificates from end-to-end, allowing you to authenticate clients that connect to HAProxy and for HAProxy to authenticate itself to your backend servers. In this blog post, you’ll learn how to set it up.

Certificate Authorities

When a client device needs to make an API call, it first establishes a secure connection to the HAProxy load balancer that’s situated in front of your servers. While making that connection, the client provides its certificate. To validate it, HAProxy checks whether it was digitally signed previously by your organization’s certificate authority.

A certificate authority (CA) is a trusted entity that can vouch for the identity of another entity. In this case of client certificates and mTLS, your organization will use its own CA to sign all client certificates that it will deploy to applications. In essence, your organization becomes its own authority, doling out certificates and then verifying them when presented back to it.

To demonstrate, we’ll create a root certificate authority for our organization. This root CA will sit at the top of our hierarchy of certificates, and as such, it would cause widespread problems if it were ever to become compromised. Therefore, we’ll also create an intermediate CA and then use the intermediate CA to sign client certificates.

Create a Root CA

A root CA sits at the top of your certification hierarchy and because there is nothing above it, it must sign its own certificate. However, it’s a good idea to create a CA that’s subordinate to that one and use the subordinate CA for signing client certificates. That way, there is less risk of the root CA being mishandled and compromised. You will still want to obtain a server SSL certificate from a provider like GoDaddy, DigiCert, Entrust, or Let’s Encrypt because server certificates must be trusted by browsers, and certificates from these providers will have that trust implicitly. The certification hierarchy we’re creating will be for client certificates only.

Continuing with the scenario of creating your own root CA, use the following openssl command:

$ openssl req \
-newkey rsa:2048 \
-nodes \
-x509 \
-days 3650 \
-keyout root-ca.key \
-out root-ca.crt

When run, the command prompts you to enter some additional information about your organization. One crucial question it asks is the name to assign to the CA. The Common Name (CN) should be independent of any server name and could be something like acme-root-ca. The command will create two files, root-ca.key and root-ca.crt.

The certificate file holds information about the CA, such as its name, expiration date, and issuer. Meanwhile, the key file contains the cryptographic bits associated with the certificate, which you can use to prove that you are the sole owner of the certificate and key. Keep the key private within your organization.

You can inspect the contents of the root-ca.crt file with the following command:

$ openssl x509 -noout -text -in root-ca.crt
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
10:4d:04:cf:6e:42:4a:87:dc:6f:5d:54:c6:f6:cd:db:70:92:28:47
Signature Algorithm: sha256WithRSAEncryption
Issuer: C = US, ST = OH, L = Columbus, O = Acme, CN = acme-root-ca, emailAddress = admin@acme.com
Validity
Not Before: Aug 4 16:40:01 2022 GMT
Not After : Aug 1 16:40:01 2032 GMT
Subject: C = US, ST = OH, L = Columbus, O = Acme, CN = acme-root-ca, emailAddress = admin@acme.com

Create an Intermediate CA

Next, let’s create a CA that’s subordinate to the root CA. You can use this intermediate CA to sign client certificates. Use the following command to create its key and certificate signing request:

$ openssl req \
-newkey rsa:2048 \
-nodes \
-days 3650 \
-keyout intermediate-ca.key \
-out intermediate-ca.csr

After asking you the same questions about your organization—for which you should choose a different Common Name—this creates two files, intermediate-ca.key and intermediate-ca.csr. The first file is this CA’s private key and the second is a certificate signing request, which you will sign with the root CA key.

Because the root CA was self-signed, its type was automatically configured as a CA certificate, as opposed to being designated an SSL server certificate or client certificate. With this new, intermediate CA, we’ll need to state explicitly that we want it to be a CA type, which we do by defining an extensions file. First, create a file named ca-cert-extensions.cnf and add extra information to append to the certificate. It should look like this:

basicConstraints = CA:TRUE
keyUsage = keyCertSign, cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer

Then use the following openssl command to sign the certificate signing request and create the certificate:

$ openssl x509 \
-req \
-in intermediate-ca.csr \
-out intermediate-ca.crt \
-CA root-ca.crt \
-CAkey root-ca.key \
-CAcreateserial \
-days 3650 \
-extfile ca-cert-extensions.cnf

This creates the file intermediate-ca.crt. Inspecting the file shows its name, expiration date, and issuer:

$ openssl x509 -noout -text -in intermediate-ca.crt
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
11:b3:9f:da:c5:fb:fc:20:69:a5:19:42:f9:58:d0:80:49:39:24:ba
Signature Algorithm: sha256WithRSAEncryption
Issuer: C = US, ST = OH, L = Columbus, O = Acme, CN = acme-root-ca, emailAddress = admin@acme.com
Validity
Not Before: Aug 4 17:02:49 2022 GMT
Not After : Aug 1 17:02:49 2032 GMT
Subject: C = US, ST = OH, L = Columbus, O = Acme, CN = acme-intermediate-ca, emailAddress = admin@acme.com

Create a Client Certificate

Now that we have an intermediate certificate, the next step is to create a client certificate request and then sign it to create a certificate. For this example, assume that the certificate will be installed into a “scanner” application running in a warehouse. Create the certificate signing request with the following command:

$ openssl req \
-newkey rsa:2048 \
-nodes \
-days 365 \
-subj "/CN=scanner/O=warehouse" \
-keyout client.key \
-out client.csr

Next, let’s create an extensions file to designate this certificate as a client certificate. Add a file named client-cert-extensions.cnf with the following contents:

basicConstraints = CA:FALSE
keyUsage = digitalSignature
extendedKeyUsage = clientAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer

Then use the following command to sign the certificate signing request:

$ openssl x509 \
-req \
-in client.csr \
-out client.crt \
-CA intermediate-ca.crt \
-CAkey intermediate-ca.key \
-CAcreateserial \
-days 365 \
-extfile client-cert-extensions.cnf

You now have a client certificate that you can use to authenticate our fictitious scanner application and gain access to an API. Use the command below to inspect the contents of the certificate:

$ openssl x509 -noout -text -in client.crt
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
40:6c:b9:0e:20:da:e5:b9:4d:b3:a4:88:84:0f:4c:72:08:40:86:83
Signature Algorithm: sha256WithRSAEncryption
Issuer: C = US, ST = OH, L = Columbus, O = Acme, CN = acme-intermediate-ca, emailAddress = admin@acme.com
Validity
Not Before: Aug 4 17:27:17 2022 GMT
Not After : Aug 4 17:27:17 2023 GMT
Subject: CN = scanner, O = warehouse

Enable Client Certificate Authentication in HAProxy

Enabling client certificate authentication in HAProxy is straightforward. Consider the frontend section below:

frontend mysite
bind 192.168.56.20:80
bind 192.168.56.20:443 ssl crt /etc/haproxy/certs/ssl.crt verify required ca-file /etc/haproxy/certs/intermediate-ca.crt ca-verify-file /etc/haproxy/certs/root-ca.crt
http-request redirect scheme https unless { ssl_fc }
default_backend apiservers

The second bind line listens on port 443 for HTTPS connections and also sets arguments needed for certificate-based authentication:

  • the ssl argument enables HTTPS

  • the crt argument specifies the server SSL certificate, which you will typically obtain from a certificate provider like Let’s Encrypt

  • the verify required argument requires clients to send a client certificate

  • the ca-file argument specifies the intermediate certificate with which we will verify that the client’s certificate has been signed with our organization’s CA

  • the ca-verify-file argument (introduced in HAProxy 2.2) includes the root CA certificate, allowing HAProxy to send a shorter list of CAs to the client in the SERVER HELLO message that will be used for verification, but keeping upper level CAs, such as the root, out of that list. HAProxy requires the root CA to be set with this argument or else included in the intermediate-ca.crt file (compatibility with older versions of HAProxy).

To test it, try the following curl command. Note that curl, acting as the client, will verify the server’s SSL certificate and give an error if it doesn’t trust it. For example, you’ll get a validation error if the SSL certificate is self-signed. That’s the mutual part of mTLS! To disable that check, including the --insecure flag.

First, try it without using the client certificate. Note the error, alert certificate is required.

$ curl -v https://192.168.56.20
* TLSv1.3 (IN), TLS alert, unknown (628):
* OpenSSL SSL_read: error:1409445C:SSL routines:ssl3_read_bytes:tlsv13 alert certificate required, errno 0
* Failed receiving HTTP2 data
* OpenSSL SSL_write: SSL_ERROR_ZERO_RETURN, errno 0
* Failed sending HTTP2 data
* Connection #0 to host 192.168.56.20 left intact
curl: (56) OpenSSL SSL_read: error:1409445C:SSL routines:ssl3_read_bytes:tlsv13 alert certificate required, errno 0

Next, try it again with the client certificate. The connection should succeed and you can examine the flow of messages that occur during the SSL handshake, during which the client certificate is requested, sent, and verified:

$ curl -v \
--cert client.crt \
--key client.key \
https://192.168.56.20
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Request CERT (13):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Certificate (11):
* TLSv1.3 (OUT), TLS handshake, CERT verify (15):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384

You may be curious why the client needs to set both its certificate and private key when connecting through curl. Practically speaking, curl will not send the private key to the server, but it does use it to calculate a hash it sends to the server, which proves to the server that the client possesses the key.

Send a Client Certificate to a Backend Server

For end-to-end authentication, HAProxy can verify the backend server’s SSL certificate and send a client certificate of its own. Consider the server line in a backend section of the HAProxy configuration below:

backend apiservers
server server1 192.168.56.30:443 ssl verify required ca-file @system-ca crt /etc/haproxy/certs/haproxy.crt

The arguments have the following meaning:

  • the ssl argument enables HTTPS communication with the server

  • the verify required argument requires HAProxy to verify the server’s SSL certificate against the CAs specified with the ca-file argument. You can set ca-file to a file or directory containing a list of certificates or, if using HAProxy 2.6 or newer, to @system-ca to load the operating system’s list of CAs.

  • the crt argument specifies the client certificate to send to the backend server

Conclusion

In this blog post, you learned how to create certificate authorities within your organization and use them to sign client certificates used for authentication. HAProxy has a straightforward way to enable certificate-based authentication, both between clients and HAProxy and between HAProxy and backend servers.

Interested to know when we publish content like this? Subscribe to our blog! You can also follow us on Twitter and join the conversation on Slack.

Subscribe to our blog. Get the latest release updates, tutorials, and deep-dives from HAProxy experts.