How to Set Up Mutual TLS Authentication to Protect Your Admin Console

Written by: Heiko Webers

So you’ve got an admin panel because it’s just easier than fiddling with the Rails console to administer the application. On the other hand, it’s a pretty sensitive place. If someone gained access, it just would be... not good.

You’ve got everything there already: strong passwords that aren’t saved somewhere in your e-mail, and TLS for the entire application and brute-force protection.

But what if the admin panel was not even reachable by anyone but you and a few others?

One way is to make it an internal application on the intranet. Another option could be to require mutual TLS authentication, i.e., the client also authenticates itself against the server with a client-side certificate. And that’s what we’re going to talk about in this post.

Setting Up Mutual TLS Authentication

Mutual authentication? How does that work? It involves creating your own Certification Authority, self-signing the server and client certificate for the admin panel, and installing your Certification Authority and the client certificate in a browser.

Does that mean you won’t need the traditional admin login protection anymore? In theory, no; it’s not needed anymore. However we’d like to add another unobtrusive level of authentication here, so we’ll keep the login form. Also, we still need the login for CSRF protection.

Existing certificates

It’s likely you already use TLS for your admin panel, so if possible, move it to a subdomain. That’ll make it easier to handle different certificates for different parts of the application.

I’m going to install mine at https://client-ssl.bauland42.com/admin. For demonstration purposes, I have a pre-installed Rails server with a popular provider, and I’ve installed a simple admin panel gem plus Devise. You can add a subdomain constraint to your routes like so:

constraints subdomain: 'admin' do
  resources :users # or ActiveAdmin.routes(self) for the ActiveAdmin gem
end

Certification Authority (CA)

We’re going to create our own CA to sign both the server and the client certificate requests. The root CA usually doesn’t sign certificates directly. Typically, they create trusted intermediate CAs to keep the root certificate keys unused as much as possible.

As we’ll use only very few certificates, we’ll skip that step. However, we’ll have to keep the root CA key very secure. Our best option would be to keep it only on a disconnected computer.

Certification Authority certificate

We’ll keep all CA keys and certificates in /etc/nginx/certs/ca for the purpose of this demo. We’ll use a key length of 4096 for the CA.

The encryption algorithms 3DES or AES with a 256 bit key length are acceptable here and a strong password is desirable. This creates a new private key with a password for the CA:

openssl genrsa -aes256 -out ca/ca.key 4096 chmod 400 ca/ca.key

Now we can create the root CA certificate with a validity of two years using the SHA256 hash algorithm (SHA1 shouldn’t be used anymore):

openssl req -new -x509 -sha256 -days 730 -key ca/ca.key -out ca/ca.crt

We’ll leave all attribute fields blank by entering .. Only the CommonName will be 42CA to identify the certificate.

chmod 444 ca/ca.crt

You can verify the root certificate with openssl x509 -noout -text -in ca/ca.crt to check the validity (2 years). The Issuer and Subject are both 42CA because this is a root certificate, which are always self-signed.

The Certificate Signing Request (CSR)

The next step is a CSR from the server which is a request to create a certificate for a specific domain name. Usually, the CA and the certificate requester are two different companies who don’t want to share their private keys. That’s why we need this middle step.

First, we’ll create a private key for the server and then the CSR. 2048 bit keys are sufficient here because the key is only valid for a year, and 4096 bits would slow down the TLS handshake. Although, of course, the admin panel will see relatively low traffic. We’re also omitting the -aes256 option this time because we don’t want to enter a password every time we start up the web server.

openssl genrsa -out server/client-ssl.bauland42.com.key 2048
chmod 400 server/client-ssl.bauland42.com.key
openssl req -new -key server/client-ssl.bauland42.com.key -sha256 -out server/client-ssl.bauland42.com.csr

For the latter, we’ll enter a dot for each CSR detail, but the Common Name has to be the server’s fully qualified domain name. In my case, it’s client-ssl.bauland42.com.

The server certificate

Now we’ll use the root CA to sign the CSR for a year. You’ll be prompted to enter the root CA’s password.

openssl x509 -req -days 365 -sha256 -in server/client-ssl.bauland42.com.csr -CA ca/ca.crt -CAkey ca/ca.key -set_serial 1 -out server/client-ssl.bauland42.com.crt
chmod 444 server/client-ssl.bauland42.com.crt

Use openssl x509 -noout -text -in server/client-ssl.bauland42.com.crt to verify the Validity, the Issuer (42CA), and the Subject (client-ssl.bauland42.com). Just to be sure, we can also verify the chain of trust. Although, it’s not really a chain -- the root CA signed the server certificate directly. Run:

openssl verify -CAfile ca/ca.crt server/client-ssl.bauland42.com.crt
server/client-ssl.bauland42.com.crt: OK

The client certificate (finally)

Generating the client certificate is very similar to creating the server certificate. The best option would be if the user created the client’s CSR so that the server wouldn’t see the user’s private key. The server would just sign the CSR and return the certificate to the user. Note that we’re using the serial number 2 for this certificate.

openssl genrsa -out client/heiko.key 2048
openssl req -new -key client/heiko.key -out client/heiko.csr
openssl x509 -req -days 365 -sha256 -in client/heiko.csr -CA ca/ca.crt -CAkey ca/ca.key -set_serial 2 -out client/heiko.crt

Configure NGINX

I’m using NGINX for this demo, and I’ve got a modern and secure SSL configuration from Mozilla. In the HTTP (port 80) configuration, I’ll redirect /admin to the HTTPS version. For the SSL server, I’ll turn ssl_verify_client on and send the root CA certificate via ssl_client_certificate. You can find the full config and other resources for the article here.

Here are the two important parts:

server {
  listen 80;
  ...
  location /admin {
    rewrite ^ https://$host$request_uri? permanent;
  }
  ...
}
server {
  listen 443 ssl;
  ...
  ssl_certificate /etc/nginx/certs/server/client-ssl.bauland42.com.crt;
  ssl_certificate_key /etc/nginx/certs/server/client-ssl.bauland42.com.key;
  ssl_client_certificate /etc/nginx/certs/ca/ca.crt;
  ssl_verify_client on;
  ...
}

Install the CA in the browser

In order to connect to the admin panel via the browser, we could ignore the browser warning when going to the admin panel. But that’s not secure, so we’ll have to install the CA certificate locally, either system-wide or only in the browser.

I’m going to install it in Firefox because that’s easiest. Use scp to securely copy the CA certificate to your computer:

scp user@IP:/etc/nginx/certs/ca/ca.crt .

Then import it in Firefox via Preferences > Advanced > Certificates > View Certificates > Authorities > Import …. I only ticked the box for This certificate can identify websites. Now reload the site in the browser, and you’ll see an expected error: 400 Bad Request, No required SSL certificate was sent. If you get other errors, restart Firefox.

Install the client certificate

To install the client certificate, we’ll need a PKCS#12 file which stores both the certificate and the client’s private key. Either you create that file, or the user could if she sent you a CSR, instead of you creating the private key for her.

openssl pkcs12 -export -clcerts -in client/heiko.crt -inkey client/heiko.key -out client/heiko.p12

Follow the prompts to complete these steps:

  1. Enter an export password which you’ll also need when importing the file.

  2. Securely copy the file with scp user@IP:/etc/nginx/certs/client/heiko.p12 . to your computer.

  3. Import it in Firefox in Preferences > Advanced > Certificates > View Certificates > Your Certificates.

  4. Restart the browser.

  5. On the Certificates tab is a When a server requests my personal certificate option. I chose Ask me every time.

  6. In the message box that appears, select a client certificate. There’s an option to remember the decision in this session.

[caption id="attachment_3111" align="aligncenter" width="504"]

Choose a client certificate in Firefox.[/caption]

Admin panel

If you go to the admin panel now, you’ll hopefully see a client certificate message box and the admin login page after that.

You’ll probably want to do a sanity check and open the page in a different browser. You’ll see the “invalid certificate authority” warning, but now you’ll proceed to the unsafe site, just for testing.

NGINX will return a "400 Bad Request, No required SSL certificate was sent“ error because we set ssl_verify_client on. If you don’t trust your browser, you can try the same with curl --insecure to ignore certificate warnings: curl --insecure https://client-ssl.bauland42.com/admin/.

Giving and Revoking Access

Make sure to encrypt the email when you send the certificate to a colleague. The very same minute, set a reminder in your calendar to renew the certificates at least a week before they expire. They usually expire in the worst moment.

Whenever issuing certificates, you also have to think about how to revoke them if, for example, someone leaves the company. There are certificate revocation lists (CRL) and the Online Certificate Status Protocol to officially revoke certificates.

However, I think the setup is overkill here and requires some ongoing maintenance. There are probably only very few people who will have access to the admin panel anyway. So I think we can whitelist the Common Names (CN) from the client certificates in Rails.

Therefore we’ll pass the subject of the client certificate in a special X-Client-Dn header from nginx to the upstream server:

location @app {
        proxy_set_header X-Client-Dn $ssl_client_s_dn;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_redirect off;
        proxy_pass http://app_server;
    }

The header will look like this:

/CN=Heiko Webers/emailAddress=42@bauland42.de

In the application, we can whitelist the common name like so:

raise unless request.headers['HTTP_X_CLIENT_DN'] =~ %r(\A\/CN=(.+)\/) && ['Heiko Webers'].include?($1)

As only people with a valid client certificate can reach the Rails application, you aren’t able to just send the header string to authenticate.

[caption id="attachment_3111" align="aligncenter" width="504"]

The green icon in Firefox[/caption]

Conclusion

For the mutual TLS authentication of sensitive areas of your app, you’ll need the following:

  • A subdomain (or a new domain) to separate the SSL configuration.

  • The web server configuration. Here’s the full NGINX example config that I used and a few hints how to do this in Apache.

  • Your own Certification Authority (CA).

  • A self-signed web server and client-side certificate. Both have to be installed in your browser.

Stay up to date

We'll never share your email address and you can opt out at any time, we promise.