Building a JWT Authenticator in Symfony 4

This tutorial is a continuation of last week’s post on creating a backend API with Symfony. Today we will be implementing authentication with a JWT. JWT stands for JSON Web Token. In practice, a JWT is generally used as a way of storing the user’s session off of the server. That way, your API can stay stateless. If you followed along from the last tutorial, currently after we login, the session is stored on the server. That’s perfectly fine to do, and it’s how a lot of websites store sessions. However, by making your API stateless you remove the extra overhead of your server managing/holding the state of all your users. This isn’t so much of a problem when your site is smaller, but if your website grows to millions of users, it will be beneficial to have a stateless API. To solve that we need to store something on the client side that lets our server know if a user is authorized to view parts of our app.

Using a JWT will help us solve this because it allows us to securely save a user identifier on the client side that we can verify coming from our server.

There are two ways we can store a JWT. We can store it in a cookie or we can store it in local storage. If you google “where to store JWT”, you will find a lot of stack overflow threads where people argue over which place is better to store it. After going through hundreds of these threads, I’ve found that cookies are the safer way to go. If you make your cookie http only, javascript will not be able to access it, meaning you would be safe against XSS attacks. The caveat to that is you are open to CSRF attacks. Though, since we are building an API and will mainly be using AJAX to make requests from our frontend app, we can add some configuration to our server that will alleviate this issue. We will go into securing your server to prevent CSRF attacks in a future tutorial.

With that little introduction out of the way, let’s get to building!

Step 1: Install a JWT package

There’s one additional package we need to install before we get started with using JWTs in our API. So to get started, open a terminal and navigate to your project’s directory and type:

composer require firebase/php-jwt

Step 2: Update Existing Authentication

Before we begin creating our JWT authenticator there are some updates we need to make to our current LoginAuthenticator as well as updates to our security.yaml config file.

First let’s update the security.yaml file. There’s one extra property we need to add so open up config/packages/security.yaml. Under security > firewalls > main add another property called stateless and set it equal to true.

That section of your security.yaml file should now look like this:

main:
    anonymous: true
    guard:
	    authenticators:
		    - App\Security\LoginAuthenticator
    logout:
	    path: api_logout
    stateless: true

Open up Postman and open your site’s url with the login route. If you still have the tab from the previous tutorial, you can use that. If you don’t, set up Postman to make a request to your login endpoint using a real account and try to login. You should be able to. However, unlike last time, if you try to view the profile endpoint, you will not be logged in anymore. That is because we’ve made our authentication stateless, so the session is no longer saved on the server, which is what we want.

Next we need to update our LoginAuthenticator.php file to save a JWT in a cookie. So open that file up. If you don’t remember its under the src/Security folder. At the top of the file we’ll need to import the JWT package we installed in the first step. Here is the use statement you need to add:

use Firebase\JWT\JWT;

Once that’s added, scroll down to the onAuthenticationSuccess method. In here, we’ll add some code that saves a cookie with our JWT stored inside. And we’re doing that in this method because we know that once the authenticator hits this method, the user has been authenticated.

To begin we need to build a payload that we’ll store inside our JWT. This payload needs to hold the user’s ID and email so we can later use that to get the logged in user throughout our app. It also should store some kind of expiration time. You can store other information in your JWT’s payload, though those are the important ones.

The payload is just a simple array. The user ID and email can be set on any key that you’d like, however, in order for the JWT library to verify that the token is not expired, we need to set the expiration time on the “exp” key. Now, you might be asking, how do I get the user ID and email from within the onAuthenticationSuccess method? If you take a look at the parameters of onAuthenticationSuccess, there’s a Request object, a TokenInterface and a $providerKey. We’ve worked with the Request object before, but haven’t used a TokenInterface before. Well, the TokenInterface has a handy method called getUser, which get’s the user that’s being logged in. So we’ll use that to grab our user ID and email! And if you wanted to know, the $providerKey is the name of our firewall. In our case it’s “main”, which we get from our security.yaml file.

Here is the code for our payload:

$expireTime = time() + 3600;
$tokenPayload = [
    'user_id' => $token->getUser()->getId(),
    'email'   => $token->getUser()->getEmail(),
    'exp'     => $expireTime
];

Next, we need to take this payload and put it into a JWT. We’ll use our JWT library that we installed to do that. The library we installed has a static function called encode, which creates our JWT. It has 2 required parameters, the first being our payload and the second is a secret key. The secret key is used to sign our JWT so that when we go to decode it, we use the key to verify that the JWT was created by us. Be sure to keep your secret key… a secret :D. A good way of storing passwordy type things that should be kept secret is in an environment variable. So let’s open our .env file and add our secret to it. For the purpose of this tutorial, I’ll call mine “secret” but if you were doing this in a production environment, make sure it’s more secure than that.

Here’s what I added to my .env file so you can follow along:

JWT_SECRET=secret

Let’s move back to our LoginAuthenticator.php file in the onAuthenticationSuccess method. We can now create the JWT. To do that use the following bit of code:

$expireTime = time() + 3600;
$tokenPayload = [
    'user_id' => $token->getUser()->getId(),
    'email'   => $token->getUser()->getEmail(),
    'exp'     => $expireTime
];

$jwt = JWT::encode($tokenPayload, getenv("JWT_SECRET"));

If you’d like to see what it looks like, add this bit of code just underneath that line:

var_dump($jwt);die;

Now head over to Postman and try to login again. You should see something like this:

string(153) "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyLCJlbWFpbCI6InRlc3RAdGVzdC5jb20iLCJleHAiOjE1NDE5MDQ3NDZ9.yrE0UwIhckTDKeTU0awFol5TpeFv2DU5H1G5yQE3d6Y"

Pretty much looks like a scrambled mess right? But let’s see what it actually is. Copy the text in between the quotes. Then open a new browser window and go to jwt.io. Once on the page, click on debugger in the top menu. Remove the default token that is on that page and paste your token inside the encoded textbox. You should see your decoded payload on the right. Your JWT is essentially a base64 encoded string. It can be decoded by anyone, so it’s important that you not store any kind of sensitive data inside the payload. It’s secure to use as a method of identifying and authenticating someone however because it is signed with our secret key. If you notice on the bottom, it says that the signature is invalid. So while anyone can see what’s inside our JWT, we are able to verify that the JWT was created by us since we sign it with our secret key.

So now that we have our JWT created, let’s save it to a cookie. Moving back to our onAuthenticationSuccess method, remove the code that outputs the JWT. To save a cookie, you can use plain old PHP. Simply use the setcookie function to save a secure http only cookie with our JWT stored inside. Since we’re coding in a development environment, you may not be able to save a secure cookie if you don’t have an SSL certificate. So you can save a non-secure cookie if you have to. But generally in a production environment you will want to save your cookie as secure. Try coding this part out yourself, and if you need help take a look at my code below:

public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
    $expireTime = time() + 3600;
    $tokenPayload = [
	    'user_id' => $token->getUser()->getId(),
	    'email'   => $token->getUser()->getEmail(),
	    'exp'     => $expireTime
    ];

    $jwt = JWT::encode($tokenPayload, getenv("JWT_SECRET"));

    // If you are developing on a non-https server, you will need to set 
    // the $useHttps variable to false
    $useHttps = true;
    setcookie("jwt", $jwt, $expireTime, "/", "", $useHttps, true);

    return new JsonResponse([
	    'result' => true
    ]);
}

Now let’s test out that our cookie is being saved. Open Postman and try logging in again. If you click on the cookies tab, you should see our jwt cookie saved!

Step 3: Setup the JWT Authenticator

Now that we have our LoginAuthenticator creating our JWT cookie, we need to make a second authenticator that authenticates based on that cookie. We’ll start the same way we built our login authenticator. In your terminal type:

php bin/console make:auth

We’re going to be making another empty authenticator. Name it JwtAuthenticator.

The next question asks you the entry point you want to use for your firewall. This is essentially, which authenticator you want to use as the default. When you have more than one authenticator, you need to tell Symfony which one to use if someone tries to access a protected area of your app. Since we need to be authenticated through the LoginAuthenticator before getting our JWT, we will use the LoginAuthenticator as our entry point.

And that’s it! Your JwtAuthenticator is now initialized and ready to be built out.

Step 4: Build out the JwtAuthenticator

Open up the src/Security folder and you will see JwtAuthenticator.php. Open up that file and you will see the same thing you saw when we initially created the LoginAuthenticator, a bunch of methods with todo comments.

We’ll start with the supports method. If you remember from the first tutorial, this method is used to check if the authenticator should be used. So, since we are using a cookie to store our JWT, if a user has our JWT cookie, this authenticator should be used.

To check cookies, we’ll use the $request parameter that’s passed into the method. The Request object has a cookies property that works similar to the request property that we used to get POST variables. So to check for our cookie your function would look like:

public function supports(Request $request)
{
    return $request->cookies->get("jwt") ? true : false;
}

This basically says, “is there a cookie named jwt? if yes return true otherwise return false”

Next up is getCredentials. In here we need to decode our JWT cookie and return the ID and email of the user that’s authenticated. To start, we need to import the JWT library with a use statement at the top of our file. Here is the code for that:

use Firebase\JWT\JWT;

The JWT library has a static function called decode that we’ll use to decode our token. It has 3 required parameters. The first being the encoded token, the second is our secret key and the third is an array of allowed hashing algorithms. The first 2 parameters we know about, but we haven’t spoken about a hashing algorithm yet. By default, when you use the encode function in the JWT library that we’re using, the algorithm gets set to HS256. There’s a few other supported algorithms you can choose from, but this one is fine to use for your JWT’s. If you want to see which other algorithms are supported, take a look at the source code for the JWT library here.

So now that we know which algorithm we used to encode our JWT, we need to send that in an array as our 3rd parameter.

Here’s the code to decode our JWT:

$cookie     = $request->cookies->get("jwt");
$decodedJwt = JWT::decode($cookie, getenv("JWT_SECRET"), ['HS256']);

The $decodedJwt variable is an object that contains our payload. So we’d be able to pull the user ID from the user_id property of the $decodedJwt variable like so:

$userId = $decodedJwt->user_id;

The same goes for the email. To get it, we would use the email property of the $decodedJwt object. However, there are a few things we need to do before we can return the user’s information. If the JWT is either expired or has an invalid signature, an exception gets thrown. So we need to surround the code that decodes the token in a try catch block. When the signature is invalid, a SignatureInvalidException exception gets thrown, and when the token is expired, an ExpiredException exception gets thrown. We first need to import these with a use statement at the top of file. Here is the code for that:

use Firebase\JWT\ExpiredException;
use Firebase\JWT\SignatureInvalidException;

We should also catch a general exception just in case any other exceptions get thrown. If one of these exceptions gets thrown we need to handle it and throw an exception that Symfony will understand. The exception: CustomUserMessageAuthenticationException is a special exception that lets us set a customer error message that gets pushed to the onAuthenticationFailure method. We need to import this class at the top of file like so:

use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;

So to recap, we need to decode our JWT in a try/catch block and return the user_id and email if it decodes correctly. Otherwise we are going to catch a few exceptions, and convert it to a nicer error message with the CustomUserMessageAuthenticationException. Try coding that out yourself and if you need help take a look at my code below:

public function getCredentials(Request $request)
{
    $cookie = $request->cookies->get("jwt");

    // Default error message
    $error = "Unable to validate session.";

    try
    {
	    $decodedJwt = JWT::decode($cookie, getenv("JWT_SECRET"), ['HS256']);
 	    return [
		    'user_id' => $decodedJwt->user_id,
		    'email' => $decodedJwt->email
	    ];

    }
    catch(ExpiredException $e)
    {
	    $error = "Session has expired.";
    }
    catch(SignatureInvalidException $e)
    {
	    // In this case, you may also want to send an email to yourself with the JWT
	    // If someone uses a JWT with an invalid signature, it could
	    // be a hacking attempt.
	    $error = "Attempting access invalid session.";
    }
    catch(\Exception $e)
    {
	   // Use the default error message
    }


    throw new CustomUserMessageAuthenticationException($error);

}

If you didn’t read my comment in the SignatureInvalidException block, it’s important to know that when this kind of exception gets thrown, it’s possible that someone is try to hack into your app. In this case, you may want to notify yourself in some way so you can look into it.

One other thing you may want to do in this function is extend the user’s session to prevent someone from being abruptly logged out. We won’t go into that in this tutorial, but we will cover it in the next one where we do some code clean up. But if you want to try extending the expiry time on the JWT yourself, go for it!

The next method we need to work on is getUser. We know that $credentials will be an array containing the the user’s ID and email from our getCredentials method. So in this method we simply need to use the $userProvider parameter to get our user with the information in our $credentials array. As we did in the LoginAuthenticator, we can use the $userProvider->loadUserByUsername method to get the user using the email key of our $credentials array.

Here’s the code for the getUser method:

public function getUser($credentials, UserProviderInterface $userProvider)
{
    return $userProvider->loadUserByUsername($credentials['email']);
}

Next up is the checkCredentials method. We can technically always return true here, since we already know that we got the user from a valid JWT. Though it can’t hurt to ensure that the user_id we saved in our JWT matches the user ID of the user we found in getUser. If you can, try writing that out yourself. Here’s my code if you need help:

public function checkCredentials($credentials, UserInterface $user)
{
    return $user->getId() === $credentials['user_id'];
}

Next is the onAuthenticationFailure method. In here we can use the exact same code as our LoginAuthenticator. Return a JsonResponse with the error in the $exception parameter.

public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
    return new JsonResponse([
	    'error' => $exception->getMessageKey()
    ], 400);
}

Be sure to import the JsonResponse object. Here is the code for that:

use Symfony\Component\HttpFoundation\JsonResponse;

In the onAuthenticationSuccess class, we actually don’t want to do anything. We want to let the request continue as normal so we can leave this method empty. If we were to return a JsonResponse like we did in our LoginAuthenticator, it would prevent the user from seeing the protected area of our API.

We can also leave the start and supportsRememberMe methods empty as well. Those methods are only really used by the entry point authenticator, which at the beginning of this tutorial we set as the LoginAuthenticator.

Step 5: Logging out

There’s one more step we need to do to get logging out working. We need to create a custom logout handler that deletes our jwt cookie. To start create a new file in the src/Security folder called JwtLogoutHandler.php. The handler needs to implement LogoutSuccessHandlerInterface which requires a single function called onLogoutSuccess and takes a Request object as its parameter.

In the onLogoutSuccess function we need to clear the jwt cookie and return a response. So, let’s create a JsonResponse object that outputs a simple result = true. Then using the JsonResponse object we created, we can delete the jwt cookie. To do that, the response object has a headers property, which has a clearCookie function. The first parameter is the name of the cookie. If you can, see if you can write that out on your own. When you’re done or need help, take a look at my code below:

<?php
namespace App\Security;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface;

class JwtLogoutHandler implements LogoutSuccessHandlerInterface
{
    public function onLogoutSuccess(Request $request)
    {
        $response = new JsonResponse(['result' => true]);
        $response->headers->clearCookie("jwt");
        return $response;
    }
}

Next we need to wire this handler up. So, in your config/packages folder, open the security.yaml file. In the logout section where we set the path in a previous tutorial, add the following bit of code:

success_handler: App\Security\JwtLogoutHandler

Here’s what that entire section should look like:

main:
	anonymous: true
	guard:
		authenticators:
			- App\Security\LoginAuthenticator
			- App\Security\JwtAuthenticator
		entry_point: App\Security\LoginAuthenticator
	logout:
		path: api_logout
		success_handler: App\Security\JwtLogoutHandler
	stateless: true

And we’re done! You can test out the login and profile routes again in Postman and it should be working as it did before we set our authentication to be stateless. At this point, you can use the API that we built across these two tutorials in your own app if you’d like! However, if you want some more practice with building an API with Symfony, next week we’ll be going over tidying up our code with some custom built services. After that, we’ll go into building a full web app!

If you have any questions or get stuck in any parts of the tutorial, feel free to leave a comment below. If you’d like to see the full code that we written so far for this tutorial click here.