Let’s say you need to configure mTLS for communication between two servers, for example, for requests from a website to an API server.
Step 1. CA
Let’s say the server with the API will be the primary one, accessible only by trusted clients, and we’ll generate certificates on it.
Let’s create a directory for certificates:
mkdir mtls-certs && cd mtls-certs
We generate the master key of our CA (it will be needed to issue all new certificates. After generating them, this key should be removed from the server for security purposes and stored in an encrypted password manager. It can also be used elsewhere to issue certificates):
openssl genrsa -out ca.key 4096
Create a root CA certificate (for 10 years):
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt -subj "/CN=ixnfo.com-Private-CA"
Step 2. Server Certificate
For convenience, the API server will be divided into two servers with different domains or IP addresses: one for testing developers and one for production.
API test server key:
openssl genrsa -out test_server.key 2048
Certificate Request (CSR):
openssl req -new -key test_server.key -out test_server.csr -subj "/CN=test.api"
We sign the server certificate with our CA:
openssl x509 -req -days 3650 -in test_server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out test_server.crt -extfile <(echo "subjectAltName=IP:192.168.10.20")
We create a certificate for the production server in a similar manner.
Step 3. Client Certificate
Similarly, there will be test and production clients that will make requests to the API server: the test client (website) to the test API, and the production client (website) to the production API.
Test client key:
openssl genrsa -out test_client.key 2048
Certificate Request:
openssl req -new -key test_client.key -out test_client.csr -subj "/CN=test.client"
We sign the client certificate:
openssl x509 -req -days 3650 -in test_client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out test_client.crt
Step 4: Configure Nginx on the server
In /etc/nginx/sites-available/ we’ll create two Nginx configurations for the test and production APIs, for example with proxy_pass if the API is in Docker:
server {
listen 192.168.10.20:443 ssl;
server_name 192.168.10.20;
ssl_certificate /etc/nginx/ssl/test_server.crt;
ssl_certificate_key /etc/nginx/ssl/test_server.key;
# mTLS
ssl_client_certificate /etc/nginx/ssl/ca.crt; # Who do we trust?
ssl_verify_client on;
location / {
# If the check fails, Nginx will return a 400 Bad Request.
proxy_pass http://127.0.0.1:8080;
proxy_set_header X-SSL-Client-S-DN $ssl_client_s_dn;
}
}
Or like this if the API is in /var/www/:
server {
listen 192.168.10.20:443 ssl;
server_name 192.168.10.20;
root /var/www/test;
index index.php index.html;
ssl_certificate /etc/nginx/ssl/test_server.crt;
ssl_certificate_key /etc/nginx/ssl/test_server.key;
# mTLS
ssl_client_certificate /etc/nginx/ssl/ca.crt;
ssl_verify_client on;
location / {
# If mTLS fails, we terminate the connection.
if ($ssl_client_verify != SUCCESS) {
return 403;
}
if ($ssl_client_s_dn !~ "CN=test.client") {
return 403;
}
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
# Passing certificate data to PHP (optional)
fastcgi_param SSL_CLIENT_S_DN $ssl_client_s_dn;
fastcgi_param SSL_CLIENT_VERIFY $ssl_client_verify;
}
# Disabling access to hidden files (.env, .git)
location ~ /\. {
deny all;
}
}
How to use this in code (PHP example):
$ch = curl_init("https://192.168.10.20/api");
curl_setopt($ch, CURLOPT_SSLCERT, "/path/to/test_client.crt");
curl_setopt($ch, CURLOPT_SSLKEY, "/path/to/test_client.key");
curl_setopt($ch, CURLOPT_CAINFO, "/path/to/ca.crt");
// If you use self-signed certificates, this is necessary to verify the hostname:
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); // Because we are contacting by IP, not by domain.
$result = curl_exec($ch);
The key files, both on the server and on the client, must have 600 rights and belong only to the user who runs the site/nginx.
Let’s copy the test_client.crt, test_client.key, and ca.crt files to the test site and test the connection using regular curl, first without the certificate, to see the error:
curl -k https://192.168.10.20/
Test with mTLS certificates (we will perform it in the directory with certificates):
curl --cacert ca.crt --cert test_client.crt --key test_client.key https://192.168.10.20/
Example for checking the integrity of files on servers:
openssl x509 -noout -modulus -in test_client.crt | openssl md5
openssl rsa -noout -modulus -in test_client.key | openssl md5
Example of viewing the server IP upon request:
echo | openssl s_client -connect 192.168.10.20:443 2>/dev/null | openssl x509 -noout -subject -ext subjectAltName
nginx -T | grep -E "server_name|ssl_certificate "
I’ve provided just an example of a setup that will allow you to successfully establish an mTLS connection, but depending on the situation, different configurations may be necessary. For example, you can improve the nginx configuration, add security headers, restrict access to port 443 for trusted clients, etc.
See also my article:
Setting up open_basedir