Secure Symfony API end-to-end with x509 certificates

Pierre Belin
Pierre Belin
Secure Symfony API end-to-end with x509 certificates
Table of Contents
Table of Contents

Symfony is an amazing framework to quickly create API. It contains the tools you need to secure your API for regular use.

Sometimes, you have to increase the security by doing end-to-end data encoding. This way, you'll be sure you won't suffer from a man-in-the-middle-attack.

Some data are critical and need more security than others. It's not about being obsessed with security, it's about protecting sensitive information. I deployed this solution for a client that needed a highly secured API.

Let's take an example. You have to develop a really simple API with 2 routes :

  • /public
  • /secured

The route /public is accessible by everyone, and /secured only by clients who use your certificate.

Generate x509 certificates

First, we'll generate the HTTPS certificate. Since we're doing security here, this is the least we can do. We will use this later.

# Certificate server (for HTTPS)
openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout server.key -out server.crt
openssl dhparam -out dhparam.pem 2048

Then, we'll generate certificates to secure the API.

# Certificate authority
openssl genrsa -out ca.key 4096
openssl req -new -x509 -days 365 -key ca.key -out ca.crt

# Certificate signing request - You need to set the correct information (here the email) with the same that you'll use in your Symfony configuration (in my case : contact@pierrebelin.fr)
openssl genrsa -out client.key 4096
openssl req -new -key client.key -out client.csr

The certification authority (usually called CA) is a digital certificate that certifies the owner of a public key. It verifies that the client's certificate was signed by itself and not by someone else.

A CA acts as a trusted third party—trusted both by the subject (owner) of the certificate and by the party relying upon the certificate. - Wikipedia

We create a certificate signing request (called a CSR) from the client key. Applying the CA digital certificate to the CSR will create a unique certificate for the client key signed by the CA.

If you need to know more, I'll let you check the internet, there are well-written articles to better explain how it works (I can't do it here otherwise it's going to be a 30-minute article 😉 ).

# Signe the key client by the CA
openssl x509 -req -days 365 -in client.csr -CA ca.crt -CAkey ca.key -set_serial 01 -out client.crt
cat client.crt client.key > client.pem

If you filled correctly the information about your certificate client, you should get this in your console.

We can see here that I filled the common name (CN) by MyCommonName and the email address (emailAddress) by contact@pierrebelin.fr. It means that this certificate belongs to me, and only me.

For our example, this is what we need:

  • One certificate signed by the CA: client.crt
  • One certificate signed by another CA: notclient.crt

This way, it tests if the security does not allow every certificate.

Configure Symfony security

To test security, we create two routes to display a message to each of them. We don't need to do anything more because we are just checking the access.

#src\TestController.php (in PHP8 annotations)
class TestController extends AbstractController
{
    #[Route('/public', name: 'public')]
    public function checkPublicAccess(): Response
    {
        return $this->json([
            'message' => 'This is my public access !',
        ]);
    }

    #[Route('/secured', name: 'secured')]
    public function checkSecuredAccess(): Response
    {
        return $this->json([
            'message' => 'This is my secured access !',
        ]);
    }
}

First, you need to install:  composer require symfony/security-bundle

Security (Symfony Docs)
Security: Screencast Do you prefer video tutorials? Check out the Symfony Security screencast series. Symfony’s security system is incredibly powerful, but it can also be confusing to set up. Don�...

The first step is to define the route which needs to be secured. You have to do it in your Symfony configuration.

#security.yaml
security:
    providers:
        client_certificate:
            memory:
                users:
                    contact@pierrebelin.fr:
                        roles: ROLE_USER
    firewalls:
        public:
            pattern: ^/public
            anonymous: true
        secured:
            pattern: ^/secured
            anonymous: false
            x509:
                provider : client_certificate
    access_control:
        - { path: ^/secured, role: ROLE_USER, requires_channel: https }

This configuration :

  • allows everyone to access to /public
  • adds access control on  /secured where the client needs to be authenticated by x509 (meaning by certificates)

At this point, you can easily access to /public but no longer to /secured without a client certificate with the email address contact@pierrebelin.fr.

Configure Nginx security

It's now about time to use our awesome certificates.

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    # HTTPS
    ssl_certificate /PATH/TO/CERT/server.crt;
    ssl_certificate_key /PATH/TO/CERT/server.key;
    
    # x509
    ssl_dhparam /PATH/TO/CERT/dhparam.pem;
    
    ssl_protocols SSLv3 TLSv1.2 TLSv1.1 TLSv1;

    root PATH;
    server_name SERVERNAME;

    index index.php index.html;

    location / {
        #try_files $uri $uri/ /index.php;
        try_files $uri /index.php$is_args$args;
    }
    location ~ \.php$ {
        fastcgi_param HTTPS on;
        fastcgi_pass unix:/var/run/php/php8.0-fpm.sock; # Change if path is different
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param SSL_CLIENT_I_DN $ssl_client_i_dn;
        fastcgi_param SSL_CLIENT_S_DN $ssl_client_s_dn; #We pass here information from the client certificate
        fastcgi_param SSL_CLIENT_VERIFY $ssl_client_verify;

        include fastcgi_params;
        fastcgi_buffers 16 32k;
        fastcgi_buffer_size 64k;
        fastcgi_busy_buffers_size 64k;
    }
}

At the same time, we are improving security by using HTTPS (the least we can do) and adding certificate interpretation. Nginx will now add parameters that contain information from the client's certificate.

SSL_CLIENT_S_DN contains all client's certificate fields. x509 authentication is based on the information inside (email address, company name, common name...).

Symfony will now have everything it needs to check the client's authority!

You should now have your API with HTTPS. This is the most important thing, otherwise, you won't be able to test.

Let's test the Symfony x509 authenticator

This is the error you should have displayed when you try to access to /secured on your browser.

It's a good start!

To validate the security, we'll create some tests and use PHPUnit.

If testing is not a common activity for you, check the Symfony documentation, you'll find everything you need.

Testing (Symfony Docs)
Testing: Whenever you write a new line of code, you also potentially add new bugs. To build better and more reliable applications, you should test your code using both functional and unit tests. The ...

We'll check the status code of each response. If the request returns a 200 code, we assume that the request was able to get the content. If not, the request was blocked by the authorization.

class WebTest extends WebTestCase
{
    public function testGetPublicWithoutCertificate(): void
    {
        $client = static::createClient([], [
            'HTTPS' => true,
        ]);
        $client->request('GET', '/public');
        self::assertResponseIsSuccessful();
    }

    public function testGetSecuredWithoutCertificate(): void
    {
        $client = static::createClient([], [
            'HTTPS' => true,
        ]);
        $client->request('GET', '/secured');
        self::assertResponseStatusCodeSame(401);
    }
}

Everything's fine!

The route /public is accessible and /secured is not anymore. Let's go deeper and check if we can have access with the right email.

We simulate the parameter SSL_CLIENT_S_DN which is supposed to be given by Nginx.

public function testGetSecuredWithSSLClientRightEmailAddress(): void
{
    $client = static::createClient([], [
        'SSL_CLIENT_S_DN' => 'emailAddress=contact@pierrebelin.fr',
        'SSL_CLIENT_VERIFY' => 'SUCCESS',
        'HTTPS' => true,
    ]);
    $client->request('GET', '/secured');
    self::assertResponseIsSuccessful();
}

public function testGetSecuredWithSSLClientWrongEmailAddress(): void
{
    $client = static::createClient([], [
        'SSL_CLIENT_S_DN' => 'emailAddress=fake@pierrebelin.fr',
        'SSL_CLIENT_VERIFY' => 'SUCCESS',
        'HTTPS' => true,
    ]);
    $client->request('GET', '/secured');
    self::assertResponseStatusCodeSame(401);
}

The fake email address return 401, great!

We tested :

  • /public is accessible
  • /secured is only accessible with the correct email address of the certificate, otherwise, it returns a 401 error.

Improve safety 100% by adding Nginx checks

At this point, your Symfony API is not totally secure.

Why?

Let's check what we have :

  • /public is accessible by everyone
  • Nginx can transfer information from the client certificate to Symfony
  • /secured is not accessible without a certificate with the email address "contact@pierrebelin.fr

Something is missing there.

You remember our 2 certificates client.crt and notclient.crt ? They both working...

Nobody checks if the certificate has been signed by the authority. This is the most important step. Imagine that someone else generates a certificate with my email address. Here, he'll be able to access to /secured.

The earlier the authenticator elements are validated, the easier it is to manage attacks on your service. By adding this security layer, you have minimized the impact of attacks on your API since Nginx will block it instead of sending the request to Symfony.

Directly in the Nginx configuration, we add a check on the client certificate.

# X509 
ssl_client_certificate /PATH/TO/CERT/ca.crt;
ssl_verify_client optional_no_ca;
ssl_verify_depth 3;

Care, the option ssl_verify_client is important to be optional_no_ca or optional but not on or you will force a certificate on the entire API.

If you want to learn more about it, you can read the Nginx documentation.

Module ngx_http_ssl_module

Then, we force the validation on our route /secured. It automatically checks the client certificate has been signed by the option ssl_client_certificate .

#...
location ~ ^(\/secured)$ { # Don't forget to change here with your own regex
	if ($ssl_client_verify != SUCCESS) {
		return 403;
		break;
	}

    try_files $uri /index.php$is_args$args;
}
#...

Important: Be careful on this step because it can create a lot of bugs. Do not forget to change the regex every time you create a new route that needs to be secured.

I recommend you to group all your critical routes inside a root route (ex: secured) to create one regex that accepts /secured and also /secured/XXXX. By doing this, you will be sure to forget any routes!

You can easily test this part with an API tool like Postman or Insomnia.

If you want to test further, I recommend you to use Guzzle. It implements an option to set a certificate as a parameter (exactly what we want to test).

Request Options — Guzzle Documentation

Go further by checking other certificates fields

The next question is: Is it possible to use x509 authenticator with other fields than an email address?

For the moment, it is not possible. In fact, as you can see, it only filters the email address.

#vendor\symfony\security-http\Authenticator\X509Authenticator.php
protected function extractUsername(Request $request): string
{
    $username = null;
    if ($request->server->has($this->userKey)) {
        $username = $request->server->get($this->userKey);
    } elseif (
        $request->server->has($this->credentialsKey)
        && preg_match('#emailAddress=([^,/@]++@[^,/]++)#', $request->server->get($this->credentialsKey), $matches)
    ) {
        $username = $matches[1];
    }

    if (null === $username) {
        throw new BadCredentialsException(sprintf('SSL credentials not found: %s, %s', $this->userKey, $this->credentialsKey));
    }
    return $username;
}

But there are many other fields, especially the common name (CN) which is also used (it was for my client).

In this case, we'll have to override the default Symfony x509 authentication to change the regex inside to validate on the other fields of the certificate like the CN.

In the first step, we declare our x509 authenticator connected to our firewall secured in the configuration.

#services.yaml
    security.authenticator.x509.secured:
        class: App\Security\X509Authenticator
        arguments:
            $userProvider: '@security.user.provider.concrete.client_certificate'
            $firewallName: 'secured'

Then, we copy the whole class into the authenticator we created and change the regex to match the CN (or another field, whichever you want).

#src\EventListener\X509Authenticator.php
protected function extractUsername(Request $request): string
{
    $username = null;
    if ($request->server->has($this->userKey)) {
        $username = $request->server->get($this->userKey);
    } elseif (
        $request->server->has($this->credentialsKey)
        // We change the regex match to check on CN fields (no more on emailAddress)    
        && preg_match('#CN=(.*?),.*#', $request->server->get($this->credentialsKey), $matches)
    ) {
        $username = $matches[1];
    }

    if (null === $username) {
        throw new BadCredentialsException(sprintf('SSL credentials not found: %s, %s', $this->userKey, $this->credentialsKey));
    }
    return $username;
}

Don't forget to change the user name in your Symfony configuration.

#security.yaml
providers:
    client_certificate:
        memory:
            users:
                MyCommonName: # Changed from contact@pierrebelin.fr
                    roles: ROLE_USER

It doesn't have to be an email. It can be your domain, your company name... Just remember that it must match the fields you filled when you generated your clients' certificates.

We simulate the parameter SSL_CLIENT_S_DN with CN to test our override.

public function testGetSecuredWithSSLClientWithCN(): void
{
    $client = static::createClient([], [
        'SSL_CLIENT_S_DN' => 'CN=MyCommonName',
        'SSL_CLIENT_VERIFY' => 'SUCCESS',
        'HTTPS' => true,
    ]);
    $client->request('GET', '/secured');
    self::assertResponseStatusCodeSame(401);
}

It works! You can now authenticate on any field you want.

Do

You now know how to secure your Symfony API end-to-end.

Let me specify that it answers a particular request for safety. Do not implement it if you need to create an API to communicate with others if you don't need to communicate without a certificate.

The 2 parts are important to check if the security is activated:

  • Symfony security.yaml to secure routes with x509 authenticator
  • Nginx .conf to handle client certificate by sending information from certificate to Symfony

Have fun!



Join the conversation.

Great! Check your inbox and click the link
Great! Next, complete checkout for full access to Goat Review
Welcome back! You've successfully signed in
You've successfully subscribed to Goat Review
Success! Your account is fully activated, you now have access to all content
Success! Your billing info has been updated
Your billing was not updated