Securing Symfony API with JWT

Securing Symfony API with JWT

tutorials

This tutorial shows you how you can secure endpoints with JSON Web Tokens in a Symfony powered API.

Table of Contents

  1. Prerequisites
  2. Create a Symfony API
  3. Secure endpoints with JWTs
  4. Conclusion

Prerequisites

Curity Identity Server

For this tutorial you will need some configuration information about your Curity Identity Server. You’ll need the Issuer value that the server uses and the JWKS endpoint (the endpoint which exposes the public keys used to verify the tokens). The JWKS value can be found at the metadata endpoint exposed by the server. The metadata endpoint can be found at {issuerUri}/.well-known/openid-configuration.

In this tutorial the following values will be used for the issuer and JWKS URIs:

Parameter NameValue in tutorial
OpenID Provider Issuer URIhttps://idsvr.example.com/oauth/anonymous
JWKS URIhttps://idsvr.example.com/oauth/anonymous/jwks

You must also know the client_id that will be used to issue the Access Tokens, as it is needed to properly verify the audience claim.

If you need to configure a new client, follow the tutorial here: Setup a Client

In this tutorial the value demo-client will be used for the client_id.

The client uses Pairwise Pseudonymous Identifier by default

By default in the Curity Identity Servers clients have the Pairwise Pseudonymous Identifiers (or PPID) option enabled. PPIDs are a way of increasing privacy of your users. The tokens issued to a client do not use the user's ID, but instead a pseudonymous, opaque ID. This means that you'll see a random string in the `sub` claim of your tokens. If, for reason, you need the original user ID in this claim, turn this feature off. We highly recommend keeping this feature on though.

This tutorial builds on the configuration setup in the “Setup and Getting Started” section and the “Setup a Username Authenticator”. If you haven’t done those steps yet you can visit those guides here:

PHP and composer installed

Make sure that you have PHP and composer installed in your system as they’ll be used in the tutorial. You can also install the symfony CLI to have an easier access to some commands.

Create a Symfony API

Initialize a Symfony app

Create the new app by following these steps:

  • Setup a new Symfony application, by calling the following command:.
symfony new secured-app
composer create-project symfony/skeleton secured-app
  • Install dependencies.

Enter the newly created directory and run the following command to install the required dependencies.

composer require spomky-labs/lexik-jose-bridge:">=3.0.2" web-token/jwt-signature-algorithm-rsa php-http/httplug-bundle php-http/curl-client nyholm/psr7 php-http/cache-plugin annotations

Select y when prompted to use composer recipes.

So what exactly are these?

  • lexik-jose-bridge is a bundle which joins together two awesome bundles that will make most of the work for us. Note that the bridge bundle is required in at least version 3.0.2. In this tutorial we’re using functionality that was introduced in this version so this is the minimum one that you should include. The essential bundles included by this one are:
    • LexikJWTAuthenticationBundle which adds possibilities for securing a Symfony app with JWTs
    • web-token/jwt-bundle which uses the jwt-framework to handle all things related to JWTs (like key management, signature validation, claims validation, etc.)
  • web-token/jwt-signature-algorithm-rsa adds support for the RSA family of signature algorithms. Should you need other algorithms you have to change this dependency to a proper one. Have a look here to check the list of supported algorithms and their respective packages.
  • php-http/httplug-bundle, php-http/curl-client, nyholm/psr7 are bundles and libraries needed to download the signature keys from the Curity Identity Server. You can exchange the curl-client library for any HTTP client that is supported by httplug. You can change the nyholm/psr7 library for any other PSR7 implementation.
  • php-http/cache-plugin adds caching mechanism so that the keys are not downloaded from Curity at each request.
  • annotations enables support for configuring routes with annotations.

Let’s move on to creating an endpoint.

  • In the src/Controller directory add an ApiController.php file.

  • Add the following to the ApiController.php file:

<?php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\User\UserInterface;

class ApiController extends AbstractController
{
    /**
     * @Route("/api", methods={"GET"})
     */
    public function handleGetApi(UserInterface $user)
    {
        $data = ['message' => 'Hello from ' . $user->getUsername()];

        return $this->json($data, 200);
    }
}

The endpoint returns a simple JSON structure with the username of the user, which will be taken straight from the incoming JWT.

  • Let’s start the app:
symfony server:start
php -S localhost:8000 -t public/

You can now open the browser and go to http://localhost:8000/api to call your API. For now you’ll see an error as we’re trying to inject a UserInterface to the method, but there are no firewalls configured that will provide the controller with a user object.

Secure endpoints with JWTs

Symfony gives you some cool features for securing endpoints in your application. The LexikJWTAuthenticationBundle adds further functionality so that using JWTs for this task is much easier. In this part you’ll learn how to configure the bundles so that the endpoints are protected and the incoming JWTs are properly validated.

New to JSON Web Tokens?

Have a look at this article if JWTs are a new thing for you.

Note on Opaque tokens

It is a common practice for an Authorization Server to issue opaque access tokens instead of JWTs. As a matter of fact, this is also the default behaviour of the Curity Identity Server. Using opaque tokens in the outside world is more secure as no data can be read from the token (as is the case with JWTs). However, it’s more convenient for an API to use JWTs as access tokens - you’ll usually want your service to have access to all the data carried in a JWT. That’s why at Curity we recommend using the Phantom token or the Split token approach, were an API gateway is responsible for exchanging the opaque token for a JWT, which is then sent together with the request to the services handling the API request.

In this tutorial we assume that you either use JWTs as access tokens for your API or use one of the mentioned approaches, so that the microservice always deals with a JWT, never an opaque token.

The configuration

  • Configure the HTTP client for downloading the public keys. In the config/packages directory open the httplug.yaml file. Replace the contents in the file with the following:
httplug:
    plugins:
        retry:
            retry: 1
        cache: # We use the cache plugin
            cache_pool: 'cache.app' # We use the PSR-6 Cache service of the application
            config:
                default_ttl: 1800 # TTL set to 30 min
    discovery:
        client: 'auto'

    clients:
        app:
            http_methods_client: true
            plugins:
                - 'httplug.plugin.content_length'
                - 'httplug.plugin.redirect'
                - 'httplug.plugin.cache'
  • In the same directory, in the lexik_jwt_authentication.yaml file add the following setting:
lexik_jwt_authentication:
# ...
  user_identity_field: sub

We want the bundle to use the sub claim from the token as the identity of the user.

  • Still in the same directory, open the spomky_labs_lexik_jose_bridge_bundle.yaml file, and replace the contents with the following:
lexik_jose:
    ttl: 3600
    key_index: 0
    server_name: 'https://idsvr.example.com/oauth/anonymous'
    audience: 'demo-client'
    key_set_remote:
        type: 'jku'
        url: 'https://idsvr.example.com/oauth/anonymous/jwks'
    signature_algorithm: "RS256"

jose:
    jku_factory:
        enabled: true
        client: 'httplug.client.app' # The Httplug client
        request_factory: 'Psr\Http\Message\RequestFactoryInterface'

The ttl and key_index settings are mandatory, but are not used for token verification, so you can put any value there. The server_name must have the value of your Authorization Server’s issuer. This value will be verified in the incoming tokens, as well as the aud claim, which has to have the value of the audience setting. The key_set_remote tells the library where the keys should be downloaded from. The signature_algorithm should be the value of the alg claim that will be used in the incoming tokens.

Here we also add configuration for the jwt-framework bundle, that tells it which http client should be used to download the public keys.

  • Finally open the security.yaml file from the same folder and replace the contents with the following:
security:
    providers:
        jwt:
            lexik_jwt: ~

    firewalls:
        api:
            provider: jwt
            pattern:   ^/api
            stateless: true
            guard:
                authenticators:
                    - lexik_jwt_authentication.jwt_token_authenticator
        
    access_control:
        - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }

Here we tell Symfony that the path /api should be secured with a JWT authenticator from the LexikJWTAuthenticationBundle. We also use a user provider from this bundle which loads users using data found inside of Access Tokens. Have a look at the bundle’s documentation to check how this provider can be adjusted.

  • Test the solution

Make a curl request to the secured endpoint:

curl -i http://localhost:8000/api

You should see a 401 response, as you haven’t sent a token.

To obtain a valid JWT you can use the online tool oauth.tools which is a powerful tool to explore OAuth and OpenID Connect. You can easily add configuration of your Curity Identity Server and use any flow to generate a valid access token. If you’re not sure how to create a JWT token using OAuth flows have a look at the Code Flow tutorial.

Once you have the token, make a request like this:

curl -i http://localhost:8000/api -H "Authorization: Bearer eyJ0e...aOCg"

This time you should see a 200 response and some JSON data.

Adding more security to the endpoint

The bundle used here validates the signature of the JWT as well as some basic claims: issuer, audience, issued at and expiration. If you want to validate other claims you can use one of the two available options:

Requiring presence of claims

To require that certain claims are present in the token, add the following setting to the spomky_labs_lexik_jose_bridge_bundle.yaml file:

lexik_jose:
#   ...
    mandatory_claims:
        - 'jti'
        - 'myClaim'

A 401 response will be returned if the incoming token does not contain the listed claims.

Validating claims with a custom validator

To add a custom claim validator follow these steps:

  • Create a Security directory in the src directory, and add a MyClaimChecker.php file inside.
  • Paste the following code into the file:
<?php

namespace App\Security;

use Jose\Component\Checker\ClaimChecker;
use Jose\Component\Checker\InvalidClaimException;

class MyClaimChecker implements ClaimChecker
{
    public function checkClaim($value): void
    {
        if ($value !== 'someConcreteValue') {
            throw new InvalidClaimException("This is a wrong value for a claim.", "myClaim", $value);
        }
    }

    public function supportedClaim(): string
    {
        return "myClaim";
    }

}

This class has to implement the ClaimChecker interface and should throw an InvalidClaimException if the value of the claim is not valid. This class will be registered as a standard Symfony service, so you can inject any other service you would need to verify the claim.

  • Next, register the service. In the config/services.yaml add the following service definition:
services:
#   ...
    App\Security\MyClaimChecker:
        tags:
            - { name: 'jose.checker.claim', alias: 'my_claim_checker' }
  • Add the following to the spomky_labs_lexik_jose_bridge_bundle.yaml file:
lexik_jose:
#   ...
    claim_checked:
        - 'my_claim_checker'

That’s it. Now the custom claim value will be checked as well.

Note that this custom claim checker will raise an exception only if the value of the claim is present and different from the one in the validator. If you want to also ensure that the claim is present at all you should also add the claim’s name to the mandatory_claims configuration option of the lexik-jose-bridge bundle.

Conclusion

Securing endpoints with JWTs in Symfony is easy and can be achieved with adding a few lines of configuration to your project. Additional verification of claims can be achieved just with a proper implementation of an interface. This together gives you a powerful tool to secure your APIs with JSON Web Tokens.

You can check out the complete code for this tutorial here: https://github.com/curityio/jwt-validation-in-symfony.

Here are links to the documentations of the different projects used in this tutorial:

Let’s Stay in Touch!

Get the latest on identity management, API Security and authentication straight to your inbox.

Keep up with our latest articles and how-tos using RSS feeds