Mutual TLS with Apache and PHP
What is mutual TLS?
Mutual TLS or mutual authentication is, in the simplest terms, the concept of client certificates.
So in exactly the same way when you connect to a website over HTTPS, the server supplies a certificate of identity which is verified by your computer against a list of trusted root certificates, there is an optional feature in TLS whereby
upon the server sending its own certificate to the client, it can additionally request a certificate from the client in a reflection of the normal TLS flow.
So the server will also ask for a certificate to verify against a certificate authority on its side, to confirm the identity of the connecting client.
The end result of a successful mutual TLS connection is that both parties have verified they hold valid certificates from accepted issuer authorities and have established a secure channel of communication.
Client certificate verification, as a proof of identity, can therefore be used as a means of authentication and authorization against a web service.
This tutorial / blog post will quickly cover how to set up Apache to perform mutual TLS, verify the client certificate and pass the details of it to a PHP script to use for authentication.
When to use mutual TLS authentication with PHP?
Mutual TLS is an alternative to other authentication mechanisms like usernames and passwords resulting in a session token, bearer token in authorization headers or JWT cookies.
I regard client certificates as a particularly good choice for business-to-business APIs exposed as a public internet service. Not only do they offer robust identification of the service user, they are easily revoked, can contain embedded data to control access privileges and most importantly, enforce encryption and secure communication by design, eliminating the possibility of a client attempting to send tokens or passwords over plain-text HTTP.
Moreover, the root certificate verifying the client certificate can be shared between systems, meaning a certificate issued by system A can be verified by system B.
Running PHP Apache in Docker
Let's start by spinning up an Apache instance with PHP installed as a module. If you have some existing local LAMP stack, XAMPP or whatever, feel free to skip this part and go straight to the bit about configuration, but I recommend ephemeral containers as a dev environment; once you get comfy with Docker it's much easier to just spin up and destroy environments as needed or on a whim than to handle multiple projects locally through a single, centralised LAMP stack.
If you don't already have it, you should install Docker.
With Docker running on your local machine, let's just pull and run the latest stable PHP on Apache on a simple page displaying information about our server.
First, create a new directory and in that directory, create an index.php
file:
<?php phpinfo();
Now from your terminal / command prompt, go in to that directory and run the following:
docker run -d -p 8080:80 --name php-apache -v "$PWD":/var/www/html php:8.0.3-apache-buster
đĄī¸ Windows users; either run this command through Powershell, or replace $PWD
with %cd%
if you're on the regular command prompt.
Once the container is up and running (you can check this by executing command docker ps
), open up a browser and hit http://localhost:8080/
You should see the PHP info page.
Great! We're running PHP 8 inside Apache, as we can see from the Server API line. But no TLS yet, let alone mutual auth. Let's move on đī¸
Creating some certificates
In the article HTTPS server in 5 minutes with Node.js I covered creating certificates with OpenSSL. We're going to do the same thing here.
Back on your command prompt, in the same directory we created our PHP file, create a new sub-directory called certs
, then cd
to that directory and run the following:
openssl genrsa -des3 -out ca.key 2048
# when prompted, enter a passphrase of your choosing
openssl req -x509 -new -nodes -key ca.key -sha256 -days 365 -out ca.crt
# when prompted, enter the passphrase from step 1
# Then enter whatever you like for the other prompts or leave as blank/defaults
openssl genrsa -out localhost.key 2048
openssl req -new -key localhost.key -out localhost.csr -addext "subjectAltName = DNS:localhost"
# Again when prompted, enter some sensible values or just leave blank
Create a file localhost.ext
in our working directory with the following contents:
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
And run the following on the command prompt:
openssl x509 -req -in localhost.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out localhost.crt -days 365 -sha256 -extfile localhost.ext
Create a new Apache config for SSL
Next up, we need to tell Apache to start serving HTTPS using the certificate we just created for localhost
. To do this, we're going to create a Dockerfile
to modify our PHP 8 Apache image and spin up a new container with a different Apache config.
First, create a new sub-directory called conf
and in that directory create a file called localhost.conf
. At this point your directory tree for this project should look like the following:
project/
ââ certs/
â ââ localhost.crt
â ââ localhost.key
â ââ ca.crt
â ââ ca.key
ââ conf/
â ââ localhost.conf
index.php
Inside localhost.conf
, paste the following and save.
<VirtualHost *:443>
ServerAdmin webmaster@localhost
DocumentRoot /var/www/html
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
SSLEngine on
SSLCertificateFile /etc/apache2/ssl/localhost.crt
SSLCertificateKeyFile /etc/apache2/ssl/localhost.key
</VirtualHost>
Create a Dockerfile
Now we need to reconfigure our Apache-PHP container to enable TLS and load up this new config. Create a file called Dockerfile
(no extension) in your root project directory and drop in the following:
FROM php:8.0.3-apache-buster
COPY certs/localhost.key certs/localhost.crt certs/ca.crt /etc/apache2/ssl/
COPY conf/localhost.conf /etc/apache2/sites-available/localhost-ssl.conf
RUN ln -s /etc/apache2/mods-available/ssl.load /etc/apache2/mods-enabled/ssl.load && ln -s /etc/apache2/sites-available/localhost-ssl.conf /etc/apache2/sites-enabled/localhost-ssl.conf
We'll use this file to build a new container image. From your command prompt, run:
docker build -t php8-apache-ssl .
Once the image has built, we can launch our new container. First stop and remove the existing container:
docker stop php-apache
docker rm php-apache
...and then run the new one from the image we just built:
docker run -d -p 8080:80 -p 4430:443 --name php-apache -v "$PWD":/var/www/html php8-apache-ssl
Now if you hit http://localhost:8080 you should see the same page, but if you hit https://localhost:4430 you should also get something; a warning from your browser that your connection might not be private!
It's okay; your browser doesn't know or trust the root certificate ca.crt
that we created so we expect this warning. You can ignore it - in Chrome for example there will be an option you can click (Advanced or something) which
will allow you to click a link and proceed anyway. Do that. Once the page loads, you will see the familiar PHP info screen. Scroll all the way down to the section headed "PHP variables" and you should see:
Great! Apache is now serving over TLS! Now let's add what we've been waiting for; mutual authentication.
Configure Apache for mututal TLS
To enable client certificates, we're going to alter our localhost.conf
file and rebuild our image. Stop and remove the existing container, then open up localhost.conf
in your text editor and change to the following:
<VirtualHost *:443>
ServerAdmin webmaster@localhost
DocumentRoot /var/www/html
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
SSLEngine on
SSLCertificateFile /etc/apache2/ssl/localhost.crt
SSLCertificateKeyFile /etc/apache2/ssl/localhost.key
SSLVerifyClient require
SSLVerifyDepth 10
SSLCACertificateFile /etc/apache2/ssl/ca.crt
<FilesMatch "\.(cgi|shtml|phtml|php)$">
SSLOptions +StdEnvVars
</FilesMatch>
</VirtualHost>
Build our Docker image
Let's rebuild our container image and run again with this new config:
docker build -t php8-apache-ssl .
docker run -d -p 8080:80 -p 4430:443 --name php-apache -v "%cd%":/var/www/html php8-apache-ssl
Now try to hit https://localhost:4430 again (you may need to hard-refresh or open a new browser session). After navigating past the warning, you should see something like this:
Great! Apache is rejecting our connection because we haven't supplied a valid client certificate. Let's generate a client certificate now.
Get the client certificate
Head back to your terminal and jump in to the project's certs
directory to run some more OpenSSL commands:
openssl genrsa -out client.key 2048
openssl req -new -sha256 -key client.key -out client.csr
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt -days 1000 -sha256
Our certs
directory now has a client.crt
and client.key
to allow us to make authenticated requests.
Authenticate with our PHP page and see the certificate details
Although it's relatively straightforward to add a client certificate to your browser, the process varies between browsers and operating systems, so I'm not going to go in to that here. Instead for demo purposes I'm using Postman to call the local server with the generated client certificate.
Making a GET
request to https://localhost:4430 with Postman appropriately configured to use my client certificate and key,
I see I get the PHP info page once more, only this time $_SERVER
has been filled with a bunch of useful variables containing details about the client certificate:
Using the certificate data to authenticate
We've seen that any PHP script now has access through the $_SERVER
superglobal to environment variables injected by our Apache config detailing the client and server certificates.
The most crucial one is $_SERVER['SSL_CLIENT_VERIFY']
- a value of SUCCESS
means we can be sure Apache has verified the client cert against the configured CA (in this example, I used the same self-signed CA for both the localhost site on
Apache and the client certificate, but this is neither required nor necessarily a good idea).
But more than that, we can see the data I embedded in my client certificate through the OpenSSL prompts when I created it - common name, organization name, organization unit, etc.
We can use these variables like $_SERVER['SSL_CLIENT_S_DN_CN']
to access this client data embedded in certificates we've issued for our app or API to hold things like usernames or other data to identify the client, link them to a
user account, set access privileges etc.
Further reading
Revoking a certificate requires some additional OpenSSL config and steps which I haven't gone in to here. Maybe in a future blog post...
Full list of SSL environment variables which can be inserted by Apache. A bit about how mutual TLS works.
Comments
All comments are pre-moderated and will not be published until approval.
Moderation policy: no abuse, no spam, no problem.
Recent posts
Learn how to build an extensible plugin system for a Symfony application
php
The difference between failure and success isn't whether you make mistakes, it's whether you learn from them.
musings coding
Buy this advertising space. Your product, your logo, your promotional text, your call to action, visible on every page. Space available for 3, 6 or 12 months.
Recalling the time I turned down a job offer because the company's interview technique sucked.
musings
Recalling the time I was rejected on the basis of a tech test...for the strangest reason!
musings
Why type hinting an array as a parameter or return type is an anti-pattern and should be avoided.
php
ėĸė ë´ėŠė ëë¤. ë§ė ëėė´ ëėėĩëë¤.
Editor's note: apparently this is Korean for "Good stuff, helped a lot."