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.

Screenshot showing 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:

keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @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:

├─ certs/
│  ├─ localhost.crt
│  ├─ localhost.key
│  ├─ ca.crt
│  ├─ ca.key
├─ conf/
│  ├─ localhost.conf

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

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:

Screenshot showing SSL

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

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:

Screenshot of unauthenticated client error

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.

Screenshot of client certificate details in PHP

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:

Screenshot of client certificate details in PHP

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.


Add a comment

All comments are pre-moderated and will not be published until approval.
Moderation policy: no abuse, no spam, no problem.

You can write in _italics_ or **bold** like this.

GOODJOB Wednesday 09 June 2021, 06:52

ėĸ‹ė€ 내ėšŠėž…니다. 많ė€ 도ė›€ė´ 되ė—ˆėŠĩ니다.

Editor's note: apparently this is Korean for "Good stuff, helped a lot."

Recent posts

Saturday 10 February 2024, 17:18

The difference between failure and success isn't whether you make mistakes, it's whether you learn from them.

musings coding

Monday 22 January 2024, 20:15

Recalling the time I turned down a job offer because the company's interview technique sucked.



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.

Get in touch

Friday 19 January 2024, 18:50

Recalling the time I was rejected on the basis of a tech test...for the strangest reason!


Monday 28 August 2023, 11:26

Why type hinting an array as a parameter or return type is an anti-pattern and should be avoided.


Saturday 17 June 2023, 15:49

Leveraging the power of JSON and RDBMS for a combined SQL/NoSQL approach.