Client Certificates with NGINX

If you want to entirely restrict access to a web server to only those folks that you deem authorized, using client certificates is the way to go. The common expression for such pattern is mutual TLS, or mTLS for short.

Outline

There are 3 components you need for this recipe:

  1. a web server that supports mTLS
  2. a certificate authority that issues a client certificate
  3. a client that will submit such client certificate to the web server as part of a request

In scope of this blog post

The following steps are being explained as part of this blog post:

  • generate a certificate request (CSR) with openssl
  • issue a client certificate with step ca
  • configure NGINX to require mTLS
  • issue an HTML request with curl

Not in scope of this blog post

There is a lot of background knowledge required to fully comprehend how mTLS works in detail. The following topics are not being addressed in this blog post and assumed to be understood to a minimum extent at least.

  • Core concepts of a Public Key Infrastructure (PKI)
  • x509 Certificates
  • OpenSSL
  • step ca
  • Import of x509 client certificates into the operating system you use

Generate a CSR with OpenSSL

For a Certificate Authority (CA) to issue a certificate to a client, a Certificate Signing Request (CSR) from the client is needed if such client wants to keep its private key as a secret to itself. An easy way to generate such is to use OpenSSL with a configuration file. Below configuration file (assumed name: john-csr.cnf) shows the bare minimum for a CSR that can be used by a CA to issue a client certificate. The only 2 net information contained in the CSR are the user name and the user’s email address.

[req]
prompt             = no
distinguished_name = req_dn
req_extensions     = req_ext

[req_dn]
CN                 = John Doe

[req_ext]
subjectAltName     = @alt_names

[alt_names]
email.1            = john.doe@examplemail.com

While we could have put the email address into the subject (req_dn section), it is important to understand that we deliberately not do this but use the subject alternative name extension to place the email address.

To generate the CSR we use openssl’s req command. We use the existing private key that we have generated beforehand and stored in the key.pem file.

andreas@laptop ➜  ~ openssl req -new -config john-csr.cnf -key key.pem -out johndoe.csr

When visualizing the generated CSR we can see the 2 net information reflected in the CSR.

andreas@laptop ➜  ~ openssl req -noout -text -in johndoe.csr                          
Certificate Request:
    Data:
        Version: 1 (0x0)
        Subject: CN = John Doe
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                RSA Public-Key: (2048 bit)
                Modulus:
                    00:e4:10:b6:3d:82:fa:ca:4b:b7:61:20:a0:33:ed:
                    ...
                    <<REDACTED>>
                    ...
                    67:ed
                Exponent: 65537 (0x10001)
        Attributes:
        Requested Extensions:
            X509v3 Subject Alternative Name: 
                email:john.doe@examplemail.com
    Signature Algorithm: sha256WithRSAEncryption
         0a:13:8b:4a:16:ee:c4:4f:23:86:f6:d8:b2:d3:7c:d6:70:d1:
         ...
         <<REDACTED>>
         ...
         3f:61:4d:cf

Issue a certificate with step ca

We will copy the CSR onto our step ca server (maybe under the incoming folder) and issue a step ca sign command. If we do have multiple provisioners, the sign command will prompt a list of all provisioners and ask us to interactively select a provisioner. After selecting the provisioner, we need to input the passphrase that protects the private key of the issuing intermediate CA.

andreas@acme ➜  ~ step ca sign --ca-url https://acme.local:8443 --root /etc/ssl/tinkivity.pem incoming/johndoe.csr issued/johndoe.pem
✔ Provisioner: 1year (JWK) [kid: SlOHMD00B8-WIyUqa1zQxP9xwG4UQCvOorMU02xThUc]
✔ Please enter the password to decrypt the provisioner key: 
✔ CA: https://acme.local:8443
✔ Certificate: issued/johndoe.pem

As an alternative we pre-select the provisioner already along with sign command.

andreas@acme ➜  ~ step ca sign --ca-url https://acme.local:8443 --root /etc/ssl/tinkivity.pem --provisioner 1year incoming/johndoe.csr issued/johndoe.pem
✔ Provisioner: 1year (JWK) [kid: SlOHMD00B8-WIyUqa1zQxP9xwG4UQCvOorMU02xThUc]
✔ Please enter the password to decrypt the provisioner key: 
✔ CA: https://acme.local:8443
✔ Certificate: issued/johndoe.pem

Another alternative is to generate a token upfront as it allows us to pass in the password file and thus make the command completely interaction free (not shown in this blog, but can be see in this previous blog post).

Either way, we can inspect the generated certificate with OpenSSL.

andreas@acme ➜  ~ openssl x509 -noout -text -in issued/johndoe.pem 
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            3f:ed:1f:01:b1:ce:90:66:0f:33:b7:31:fa:ce:b9:8a
    Signature Algorithm: sha256WithRSAEncryption
        Issuer: C=DE, ST=Saxony, O=Tinkivity, OU=Tinkivity Intermediate Certificate Authority, CN=Smallstep Intermediate CA/emailAddress=xxx@xxx.com
        Validity
            Not Before: Dec  6 12:58:16 2020 GMT
            Not After : Dec  6 12:59:16 2021 GMT
        Subject: CN=John Doe
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
                Modulus:
                    00:e4:10:b6:3d:82:fa:ca:4b:b7:61:20:a0:33:ed:
                    ...
                    <<REDACTED>>
                    ...
                    67:ed
                Exponent: 65537 (0x10001)
        X509v3 extensions:
            X509v3 Key Usage: critical
                Digital Signature, Key Encipherment
            X509v3 Extended Key Usage: 
                TLS Web Server Authentication, TLS Web Client Authentication
            X509v3 Subject Key Identifier: 
                7D:A9:C5:44:49:EC:CC:54:37:64:46:CF:A9:99:85:D6:23:18:9D:F8
            X509v3 Authority Key Identifier: 
                keyid:87:32:28:49:63:29:06:79:96:13:DE:47:14:9F:EF:C0:DD:EC:4D:C3

            X509v3 Subject Alternative Name: 
                email:john.doe@examplemail.com
            1.3.6.1.4.1.37476.9000.64.1: 
                07.....1year.+SlOHMD00B8-WIyUqa1zQxP9xwG4UQCvOorMU02xThUc
    Signature Algorithm: sha256WithRSAEncryption
    Signature Algorithm: sha256WithRSAEncryption
         76:46:dc:d0:c7:81:ab:f3:c0:3c:0f:5c:99:d1:12:ca:97:a1:
         ...
         <<REDACTED>>
         ...
         a7:e7:56:13:79:3d:3c:b0

Setup NGINX for mTLS

Assuming we re-use the NGINX setup from this previous blog post, we only have to add a few lines to the site configuration at /usr/local/etc/nginx/sites/testclient.conf from our NGINX server.

server {
#       listen       80;
        server_name  testclient;

        access_log /var/log/nginx/testclient.access.log;
        error_log /var/log/nginx/testclient.error.log;

        # location of our own root certificate
        ssl_client_certificate  /etc/ssl/certs/97efb5b5.0;
        ssl_verify_client       optional;

        location / {
            root   /usr/local/www/sites/testclient/html;
            index  index.html;

            if ($ssl_client_verify != SUCCESS) {
                    return 403;
            }
        }

        listen 443 ssl;
        ssl_certificate /usr/local/etc/letsencrypt/live/testclient/fullchain.pem;
        ssl_certificate_key /usr/local/etc/letsencrypt/live/testclient/privkey.pem;
        include /usr/local/etc/letsencrypt/options-ssl-nginx.conf;
        ssl_dhparam /usr/local/etc/letsencrypt/ssl-dhparams.pem;
}

server {
        if ($host = testclient) {
            return 301 https://$host$request_uri;
        }

        listen       80;
        server_name  testclient;
        return 404;
}

Now we only have NGINX reload the configuration.

andreas@testclient ➜  ~ sudo service nginx reload
Performing sanity check on nginx configuration:
nginx: the configuration file /usr/local/etc/nginx/nginx.conf syntax is ok
nginx: configuration file /usr/local/etc/nginx/nginx.conf test is successful

Issue a request with client authentication

Let’s start by trying to issue a regular request from our laptop. We use a simple curl command to callout to https://testclient. We tell curl where to find our root certificate via the –cacert option, so it will not complain about an unknown root CA.

According to the NGINX configuration, we should immediately receive a 403 error from the web server if we lack client authentication…

andreas@laptop ➜  ~ curl --cacert /etc/ssl/certs/97efb5b5.0 https://testclient
<html>
<head><title>403 Forbidden</title></head>
<body>
<center><h1>403 Forbidden</h1></center>
<hr><center>nginx/1.18.0</center>
</body>
</html>

Now, let’s apply our user certificate and the private key we’ve used to generate the CSR.

andreas@laptop ➜  ~ curl --cacert /etc/ssl/certs/97efb5b5.0 --cert johndoe.pem --key key.pem https://testclient
<html>
    <head>
        <title>TESTCLIENT</title>
    </head>
    <body>
        <h1>Hello World!</h1>
    </body>
</html>