Configuring Client Certificate Authentication on Nginx in Docker

Create a directory where we will configure the container and navigate into it.

shell

sudo mkdir /opt/docker && cd $_

Create a file named docker-compose.yml with two containers — one for Nginx and one for Certbot for updating the Let’s Encrypt certificates.

yaml

services:
  nginx:
    image: nginx:latest
    restart: unless-stopped
    # route ports
    ports:
      - "80:80"
      - "443:443"
    # mount directories from the current folder into the container
    volumes:
      - certbot-crt:/etc/letsencrypt:ro
      - certbot-tmp:/var/certtmp:ro
      - ./nginx:/etc/nginx/conf.d:ro
    command: "/bin/sh -c 'while :; do sleep 12h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"

  certbot:
    image: certbot/certbot:latest
    restart: unless-stopped
    volumes:
      - certbot-crt:/etc/letsencrypt
      - certbot-tmp:/var/certtmp
    depends_on:
      - nginx
    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"

volumes:
  certbot-crt:
  certbot-tmp:

The /var/certtmp directory in the nginx container must match the path specified in the site’s configuration file with root /var/certtmp;.

Create a simple configuration for your site in the file nginx/example.com.conf. Make sure that your domain points to the IP of this server.

nginx

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    location / {
        add_header Content-Type text/plain;
        return 200 "Hello to client from $remote_addr!\n";
    }
    
    # Needed for obtaining certificates from Let's Encrypt
    location ~ /.well-known/acme-challenge {
        allow all;
        root /var/certtmp;
    }
}

Do not forget to replace all instances of example.com with your own domain. Reload the Nginx configuration.

shell

docker compose exec nginx nginx -s reload

First, run in test mode with the --dry-run parameter to check that the configuration is correct.

shell

docker compose run --entrypoint "certbot certonly --webroot --webroot-path=/var/certtmp --dry-run -d example.com -d www.example.com" certbot

If everything is fine, you should see the following message:

[+] Creating 1/0
 ✔ Container docker-nginx-1  Running                    0.0s 
 Saving debug log to /var/log/letsencrypt/letsencrypt.log
 Account registered.
 Simulating a certificate request for example.com
 The dry run was successful.

Now, request the certificate.

shell

docker compose run --entrypoint "certbot certonly --webroot --webroot-path=/var/certtmp -d example.com -d www.example.com" certbot

Agree to the terms and conditions and obtain the certificate. If everything is successful, the output will include the path where the certificate files are saved. Next, update your configuration in nginx/example.com.conf.

nginx

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name example.com www.example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    
    location / {
        add_header Content-Type text/plain;
        return 200 "Hello to client from $remote_addr!\n";
    }
    
    # Needed for obtaining certificates from Let's Encrypt
    location ~ /.well-known/acme-challenge {
        allow all;
        root /var/certtmp;
    }
}

Reload the Nginx configuration and test. Your site should now be accessible via https.

shell

curl https://example.com

First, we need to create a certificate for the Certificate Authority (CA). We will use it to sign client certificates and verify their authenticity.

Info
Important! When creating the CA and the client certificates, the Organization Name fields must be different! Otherwise, client authentication will fail.

Create a separate directory to store certificates and perform all the operations there. For security reasons, this directory should not be kept inside a container. Create it, for example, in the user’s home directory.

Create the CA’s private key and generate the CA certificate.

shell

mkdir ~/mycompanycert && cd $_
openssl ecparam -name secp384r1 -genkey -out companyname.ca.key
openssl req -new -x509 -days 36500 -sha512 -key companyname.ca.key -out companyname.ca.crt

Be sure to specify the original Organization Name for the Certificate Authority. It is recommended to fill in the other fields with real data.

Copy the generated certificate companyname.ca.crt into the configuration folder /opt/docker/nginx. Then add your created CA into your site’s Nginx configuration for client certificate verification and enable forced client certificate verification.

nginx

server {
    ...
    ssl_client_certificate conf.d/companyname.ca.crt;
    ssl_verify_client on;
    ...
}

Reload the Nginx configuration. After this setting, access to the site without a client certificate should be blocked.

shell

docker compose -f /opt/docker/docker-compose.yml exec nginx nginx -s reload

Ideally, user keys should be created on their devices so that the private key never leaves them or is transmitted over the network. For signing, only the .csr (certificate signing request) file is needed. However, often users are not able to create them and the entire process falls to the administrator.

Create a private key for the user and generate a certificate signing request. It is advisable to correctly fill in the certificate fields to facilitate later control.

shell

openssl ecparam -name secp384r1 -genkey -out user1.key
openssl req -new -key user1.key -out user1.csr

Create a user certificate signed by our Certificate Authority.

shell

openssl x509 -req -CA companyname.ca.crt -CAkey companyname.ca.key -CAcreateserial -days 36500 -sha512 -in user1.csr -out user1.crt

You can verify the correctness of the created certificate. There should be no message regarding a self-signed certificate.

shell

openssl verify -verbose -CAfile companyname.ca.crt user1.crt

To test if everything works correctly, try connecting to the server using the certificate.

shell

curl --cert user1.crt --key user1.key  https://example.com

It is not recommended to transfer the private key in plain text. For this, you need to create a secure container, such as in the .p12 format. All modern devices support this format.

It is not necessary to transfer our center’s CA certificate to users. It is not used during client authorization. It should only be installed on users’ systems if you intend to issue certificates for your HTTPS server yourself (for example, for servers with local names or that are not accessible from the Internet). In that case, users’ browsers will not flag them as untrusted.

shell

openssl pkcs12 -export -in user1.crt -inkey user1.key -out user1.p12 -passout pass:SuperPassword

Checking the obtained container.

shell

openssl pkcs12 -info -nodes -in user1.p12

Pay attention to the line with PKCS7 Encrypted data – it should not contain any DES, 3DES, MD5, or other deprecated formats. If it does, please update your openssl version.

Now you can send this file to the users, and transmit its password via another secure channel along with instructions on how to import it into the system.

Create a file named adduser.sh to add a user. Edit the SUBJ parameter to set your organization, city, and region name.

shell

#!/bin/sh

USER=$1
PASSWORD=$2
CACRT=companyname.ca.crt
CAKEY=companyname.ca.key
SUBJ="/C=RU/ST=State/L=City/O=Organization Name/CN=$USER"
DAYS=36500

if [ -z "$1" ]; then
  echo $0 \"User Name\" [Password for p12]
  exit 1
fi

if [ ! -z "$PASSWORD" ]; then
  PASSWORD="-passout pass:$PASSWORD"
fi

openssl ecparam -name secp384r1 -genkey -out "$USER.key"
openssl req -new -subj "$SUBJ" -key "$USER.key" -out "$USER.csr"
openssl x509 -req -CA "$CACRT" -CAkey "$CAKEY" -CAcreateserial -days "$DAYS" -sha512 -in "$USER.csr" -out "$USER.crt"
openssl verify -verbose -CAfile "$CACRT" "$USER.crt"
openssl pkcs12 -export -in "$USER.crt" -inkey "$USER.key" -out "$USER.p12" $PASSWORD

Make the script executable:

shell

chmod +x adduser.sh

Then create a user:

shell

./adduser.sh "Harry Smith" SuperPassword

All information about revoked certificates will be stored in the same directory as the certificates. For this, create a subdirectory demoCA (or use another name if you have modified the /etc/ssl/openssl.cnf file; you can check the parameter dir in the [ CA_default ] section) along with the necessary files. They will contain a registry of revoked certificates, from which a signed file with the revoked certificates named crl.pem will be created for use—for example, in Nginx.

shell

cd ~/mycompanycert
mkdir demoCA
touch demoCA/index.txt
echo 1000 > demoCA/crlnumber

Although we currently have no revoked certificates, we can already generate a file for them that will be added to the Nginx configuration. It needs to be copied into the settings directory for the website. In our case, that is /opt/docker/nginx/.

shell

openssl ca -keyfile companyname.ca.key -cert companyname.ca.crt -gencrl -out companyname.crl.pem
sudo cp companyname.crl.pem /opt/docker/nginx/

Now add the following configuration to Nginx to check for revoked certificates.

nginx

server {
    ...
    ssl_crl conf.d/companyname.crl.pem;
    ...
}

Reload the Nginx configuration and test the setup. At this point, all certificates should be accepted, and access without a valid certificate should be blocked.

Next, revoke the user’s certificate.

shell

openssl ca -revoke user1.crt -keyfile companyname.ca.key -cert companyname.ca.crt

Update the file containing the list of revoked certificates.

shell

openssl ca -keyfile companyname.ca.key -cert companyname.ca.crt -gencrl -out companyname.crl.pem
sudo cp companyname.crl.pem /opt/docker/nginx/

Finally, reload the Nginx configuration and test it.

Related Content