How to create an API with Symfony 4 and JWT

Joey Masip Romeu
11 min readJan 23, 2019

Today we’re going to create a Symfony 4 API web app from scratch — I’ll walk you through all the steps, so by the end of this tutorial, you should be able to create, configure and run a web app with API endpoints and protected with JWT authentication.

Also, I’ve uploaded all the source code here so you can follow through the tutorial or you can download the code and play with it while you read.

1. Docker

To set our development environment, we’ll use Docker — you probably already know by now how much I love Docker 🙂

Let’s start by creating a docker-compose.yaml file with php7, mysql for database and nginx for the webserver.

#docker-compose.yaml
version: "3.1"
volumes:
db-data:
services:
mysql:
image: mysql:5.6
container_name: ${PROJECT_NAME}-mysql
working_dir: /application
volumes:
- db-data:/application
environment:
- MYSQL_ROOT_PASSWORD=docker_root
- MYSQL_DATABASE=sf4_db
- MYSQL_USER=sf4_user
- MYSQL_PASSWORD=sf4_pw
ports:
- "8306:3306"
webserver:
image: nginx:alpine
container_name: ${PROJECT_NAME}-webserver
working_dir: /application
volumes:
- .:/application
- ./docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf
ports:
- "8000:80"
php-fpm:
build: docker/php-fpm
container_name: ${PROJECT_NAME}-php-fpm
working_dir: /application
volumes:
- .:/application
- ./docker/php-fpm/php-ini-overrides.ini:/etc/php/7.2/fpm/conf.d/99-overrides.ini
environment:
XDEBUG_CONFIG: "remote_host=${localIp}"

Also, don’t forget the .env file with your PROJECT_NAME variable. On this ocasion, for the php-fpm and the nginx, I’m pointing to docker folder, so I can override the nginx.conf file and the php-fpm Dockerfile with special configuration, such as xdebug.

Link to docker/nginx/nginx.conf file
Link to docker/php-fpm/Dockerfile
Link to docker/php-fpm/php-ini-overrides.ini

Once we’re ready, we can build and run.

docker-compose build
docker-compose up -d

2. Creating a Symfony project

First, let’s go into the bash

docker-compose exec php-fpm bash

Let’s create a symfony 4 project

#inside php-fpm bash
composer create-project symfony/website-skeleton symfony

Clean up

#inside php-fpm bash
mv /application/symfony/* /application
mv /application/symfony/.* /application
rm -Rf /application/symfony

More on how to create a symfony 4 application with docker here

If we open the browser http://localhost:8000/ we should see the Symfony welcome page. So now that we have a symfony 4 app up and running, let’s start putting stuff into it!

3. Mapping our User in the database

Inside the php-fpm bash, let’s start with installing the FOSUserBundle to have a User base entity we can relate to.

#inside php-fpm bash
composer require friendsofsymfony/user-bundle "~2.0"

After donwloading the packages and clearing the cache…you’ll probably get the error

"The child node "db_driver" at path "fos_user" must be configured." 

Don’t panic, this is unfortunately normal. Reason being is it’s trying to clear the cache before configuration is correct.

3.1 Configuriation

#config/services.yaml# FOS user config
fos_user:
db_driver: orm # other valid values are 'mongodb', 'couchdb' and 'propel'
firewall_name: main
user_class: App\Entity\User
from_email:
address: "no-reply@joeymasip.com"
sender_name: "Joey"
registration:
# form:
# type: AppBundle\Form\UserRegisterType
confirmation:
enabled: true
template: FOSUserBundle:Registration:email.txt.twig
from_email:
address: "no-reply@joeymasip.com"
sender_name: "No Reply Registration"
service:
mailer: fos_user.mailer.twig_swift
resetting:
email:
template: FOSUserBundle:Resetting:email.txt.twig

3.2 Creating the User class

namespace App\Entity;use FOS\UserBundle\Model\User as BaseUser;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="fos_user")
*/
class User extends BaseUser
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
*/
protected $id;
public function __construct()
{
parent::__construct();
// your own logic
}
}

3.3 Configuring main firewall

#config/packages/security.yaml
security:
encoders:
FOS\UserBundle\Model\UserInterface: bcrypt
Symfony\Component\Security\Core\User\User: plaintext
role_hierarchy:
ROLE_ADMIN: ROLE_USER
ROLE_SUPER_ADMIN: ROLE_ADMIN
providers:
chain_provider:
chain:
providers: [in_memory, fos_userbundle]
in_memory:
memory:
users:
superadmin:
password: 'superadminpw'
roles: ['ROLE_SUPER_ADMIN']
fos_userbundle:
id: fos_user.user_provider.username
access_control:
- { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/admin/, role: ROLE_ADMIN }
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
pattern: ^/
form_login:
provider: chain_provider
csrf_token_generator: security.csrf.token_manager
login_path: fos_user_security_login
check_path: fos_user_security_check
always_use_default_target_path: false
default_target_path: admin_admin_index
logout:
path: fos_user_security_logout
target: fos_user_security_login
anonymous: true

3.4 Creating the register API endpoint

Now that we have a User entity mapped in our database, let’s create a register API endpoint so we can add new users.

I’ve created an Api folder, and added in routes.yaml

#config/routes.yaml
api:
prefix: /api
resource: '../src/Controller/Api'

So all our API endpoints will have the prefix api

So now let’s create a Controller for registering our users.

namespace App\Controller\Api;use FOS\UserBundle\Model\UserManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use App\Entity\User;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @Route("/auth")
*/
class ApiAuthController extends AbstractController
{
/**
* @Route("/register", name="api_auth_register", methods={"POST"})
* @param Request $request
* @param UserManagerInterface $userManager
* @return JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse
*/
public function register(Request $request, UserManagerInterface $userManager)
{
$data = json_decode(
$request->getContent(),
true
);
$validator = Validation::createValidator(); $constraint = new Assert\Collection(array(
// the keys correspond to the keys in the input array
'username' => new Assert\Length(array('min' => 1)),
'password' => new Assert\Length(array('min' => 1)),
'email' => new Assert\Email(),
));
$violations = $validator->validate($data, $constraint); if ($violations->count() > 0) {
return new JsonResponse(["error" => (string)$violations], 500);
}
$username = $data['username'];
$password = $data['password'];
$email = $data['email'];
$user = new User(); $user
->setUsername($username)
->setPlainPassword($password)
->setEmail($email)
->setEnabled(true)
->setRoles(['ROLE_USER'])
->setSuperAdmin(false)
;
try {
$userManager->updateUser($user, true);
} catch (\Exception $e) {
return new JsonResponse(["error" => $e->getMessage()], 500);
}
return new JsonResponse(["success" => $user->getUsername(). " has been registered!"], 200);
}
}

Note: validation and data handling for user creation should be decoupled from the controller, it has been put together just for the example.

If you now send a POST request with the data to http://localhost:8000/api/auth/register, you should get a registered user, and validation error if the data in the json is incorrect or the keys some keys are missing. Also, you’ll get a doctrine error if the username or emails you try to add in the database already exist, as they are unique keys in the FOSUserBundle base User we’re using.

curl -X POST -H "Content-Type: application/json" http://localhost:8000/api/auth/register -d '{"username":"patata","password":"fregida", "email":"patatafregida@joeymasip.com"}'

4. LexikJWTAuthenticationBundle

Now it’s time for the login and recieving a token. For this, we’ll use JWT.

#inside php-fpm bash
composer require lexik/jwt-authentication-bundle

4.1 Private and Public keys

First, let’s create the private and public keys for our project, with a passphrase.

#inside php-fpm bash
mkdir config/jwt
openssl genrsa -out config/jwt/private.pem -aes256 4096
openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem

4.2 Configuration

Once you’ve created the keys, you can add the config in the yaml and .env files. The passphrase you created the keys with must relate to the config.

#config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: 3600
#.env
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=sf4jwt

4.3 Routes

Once we’ve created the keys and configured the bundle, it’s time to add the login route in our routes.yaml

#config/routes.yaml
api_auth_login:
path: /api/auth/login
methods: [POST]
api:
prefix: /api
resource: '../src/Controller/Api'

Note: it’s important to put the specific routes before the main ones. See that /api/auth/login is more specific than /api

4.4 Firewalls

And now we have to tell our app to handle this route through configuration, since we won’t be implementing it in our controller.

#config/packages/security.yaml
security:
#...
firewalls:
dev:
#...
api_login:
pattern: ^/api/auth/login
stateless: true
anonymous: true
json_login:
provider: chain_provider
check_path: /api/auth/login
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
provider: chain_provider
main:
#...

Again, make sure to put api firewalls before the main. The pattern used for the “main” firewall catches everything, the pattern for “api” catches “/api”, so you should put the wildcard AKA main at the end, after the specific cases.

If we now send a POST request to http://localhost:8000/api/auth/login with the username and password from the user we created earlier, you should get a response with the token!

curl -X POST -H "Content-Type: application/json" http://localhost:8000/api/auth/login -d '{"username":"patata","password":"fregida"}'

We’ll get the 200 response

{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOi..."}

All of this is great!

Now it’s time to protect our API calls now that we have tokens right? This is done by creating a new firewall in our security.yaml.

#config/packages/security.yaml
security:
#...
firewalls:
dev:
#...
api_login:
#...
api:
pattern: ^/api
stateless: true
anonymous: false
provider: chain_provider
guard:
authenticators:
- lexik_jwt_authentication.jwt_token_authenticator
main:
#...

Now if we try to call to our previous route http://localhost:8000/api/auth/register without any Authorization header, we’ll see that we get a 401 error.

{
"code": 401,
"message": "JWT Token not found"
}

This doesn’t make sense because someone who has to register doesn’t have a token yet! Let’s add one last firewall so anonymous users can register.

#config/packages/security.yaml
security:
#...
firewalls:
dev:
#...
api_login:
#...
api_register:
pattern: ^/api/auth/register
stateless: true
anonymous: true
api:
#...
main:
#...

To authenticate in our api calls, we just need to add an Authorization header with the Bearer prefix followed by the JWT token, so the value of the header could be for example:

Bearer eyJ0eXAiOiJKV1QiLCJhbGc...

Since we added the anonymous in the /api/auth/register pattern in our firewall, we should now be able to register without sending any token in the header.

Moreover, if you want to open API calls even without a JWT token in the Authorization header, you can just set anonymour to true in the api firewall, like so:

#config/packages/security.yaml
security:
#...
firewalls:
dev:
#...
api_login:
#...
api_register:
#...
api:
#...
anonymous: true
#...
main:
#...

Now we can add the ACL to control access to fully secure all the prefix routes in our security.yaml

#config/packages/security.yaml
security:
#...
access_control:
#...
- { path: ^/api/auth/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/api/auth/register, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }

One last thing, once we register, it would be nice to receive the token inmediately. It would be wierd for an app to make you register and then make you login afterwards. We can redirect to the /auth/login route once a User has been created and return the response.

So in our previous ApiAuthController,

namespace App\Controller\Api;/**
* @Route("/auth")
*/
class ApiAuthController extends AbstractController
{
/**
* @Route("/register", name="api_auth_register", methods={"POST"})
* @param Request $request
* @param UserManagerInterface $userManager
* @return JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse
*/
public function register(Request $request, UserManagerInterface $userManager)
{
#...
# Code 307 preserves the request method, while redirectToRoute() is a shortcut method.
return $this->redirectToRoute('api_auth_login', [
'username' => $data['username'],
'password' => $data['password']
], 307);
}
}

5. NelmioApiDocBundle

Let’s add some docs in our API project.

#inside php-fpm bash
composer require nelmio/api-doc-bundle

5.1 Configuration

#config/packages/nelmio_api_doc.yaml
nelmio_api_doc:
documentation:
# schemes: [http, https]
info:
title: Symfony JWT API
description: Symfony JWT API docs
version: 1.0.0
securityDefinitions:
Bearer:
type: apiKey
description: 'Authorization: Bearer {jwt}'
name: Authorization
in: header
security:
- Bearer: []
areas: # to filter documented areas
path_patterns:
- ^/api(?!/doc$) # Accepts routes under /api except /api/doc

5.2 Routing

Also uncomment the app.swagger_ui route in

#config/routes/nelmio_api_doc.yaml
app.swagger_ui:
path: /api/doc
methods: GET
defaults: { _controller: nelmio_api_doc.controller.swagger_ui }

5.3 ACL

Finally, we want to add this to our ACL

#config/packages/security.yaml
security:
#...
access_control:
#...
- { path: ^/api/doc, roles: IS_AUTHENTICATED_ANONYMOUSLY }
#...
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }

Again, remember to put this specific route before the generic /api prefix

If we open the browser http://localhost:8000/api/doc we should see the swagger with the jwt api key auth for secured routes.

6. NelmioCorsBundle

Cross-origin resource sharing is a mechanism that allows restricted resources on a web page to be requested from another domain outside the domain from which the first resource was served. So if we want to access our API from a different domain we’ll probably run into CORS problems, so let’s quickly set up NelmioCorsBundle!

#inside php-fpm bash
composer req cors

Change the default configuration from this

#config/packages/nelmio_cors.yaml
nelmio_cors:
defaults:
origin_regex: true
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
allow_headers: ['Content-Type', 'Authorization']
expose_headers: ['Link']
max_age: 3600
paths:
'^/': ~

To this

#config/packages/nelmio_cors.yaml
nelmio_cors:
defaults:
allow_credentials: false
allow_origin: []
allow_headers: []
allow_methods: []
expose_headers: []
max_age: 0
hosts: []
origin_regex: false
forced_allow_origin_value: ~
paths:
'^/api/':
allow_origin: ['*']
allow_headers: ['Content-Type', 'Authorization']
allow_methods: ['POST', 'PUT', 'GET', 'DELETE']
max_age: 3600
'^/':
origin_regex: true
allow_origin: ['^http://localhost:[0-9]+']
allow_headers: ['Content-Type', 'Authorization']
allow_methods: ['POST', 'PUT', 'GET', 'DELETE']
max_age: 3600
hosts: ['^api\.']

This will allow all origins for the /api prefix so any mobile app can now use our API. Feel free to play with the regex until you’re comfortable with the result.

7. Creating an example API enpoint

As an example, let’s create an API endpoint for retrieving a user.

I’ll create a new controller

namespace App\Controller\Api;use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
/**
* @Route("/user")
*/
class ApiUserController extends AbstractController
{
/**
* @Route("/{id}", name="api_user_detail", methods={"GET"})
* @param User $user
* @return JsonResponse
*/
public function detail(User $user)
{
$this->denyAccessUnlessGranted('view', $user);
return new JsonResponse($this->serialize($user), 200);
}
protected function serialize(User $user)
{
$encoders = [new XmlEncoder(), new JsonEncoder()];
$normalizers = [new ObjectNormalizer()];
$serializer = new Serializer($normalizers, $encoders); $json = $serializer->serialize($user, 'json'); return $json;
}
}

Note: Please consider serializing in a separate service, this is a just some example code.

We’ll need a voter to let us know if a user is allowed to be retrieved or not. It should only be retrieved if it’s himself, or if it’s an admin right?

namespace App\Security;use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
class UserVoter extends Voter
{
// these strings are just invented: you can use anything
const VIEW = 'view';
const EDIT = 'edit';
private $decisionManager; public function __construct(AccessDecisionManagerInterface $decisionManager)
{
$this->decisionManager = $decisionManager;
}
protected function supports($attribute, $subject)
{
// if the attribute isn't one we support, return false
if (!in_array($attribute, array(self::VIEW, self::EDIT))) {
return false;
}
// only vote on User objects inside this voter
if (!$subject instanceof User) {
return false;
}
return true;
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
$user = $token->getUser();
if (!$user instanceof User) {
// the user must be logged in; if not, deny access
return false;
}
// ROLE_SUPER_ADMIN can do anything! The power!
if ($this->decisionManager->decide($token, array('ROLE_ADMIN'))) {
return true;
}
// you know $subject is a User object, thanks to supports
/** @var User $userSubject */
$userSubject = $subject;
switch ($attribute) {
case self::VIEW:
return $this->canView($userSubject, $user);
case self::EDIT:
return $this->canEdit($userSubject, $user);
}
throw new \LogicException('This code should not be reached!');
}
private function canView(User $userSubject, User $user)
{
// if they can edit, they can view
if ($this->canEdit($userSubject, $user)) {
return true;
}
// the User object could have, for example, a method isPrivate()
// that checks a boolean $private property
return $user === $userSubject;
}
private function canEdit(User $userSubject, User $user)
{
// this assumes that the data object has a getOwner() method
// to get the entity of the user who owns this data object
return $user === $userSubject;
}
}

As you can see, we can get the user from the TokenInterface. The function

$tokenInterface->getUser()

will return a UserInterface aka our User. The TokenInterface can be injected in an EventListener, Controller, etc. so you can always know what user is making the request.

More on how to use voters for securing your app here.

Let’s try getting that user with the GET request.

curl -X GET -H "Content-Type: application/json" http://localhost:8000/api/user/1

The response is a 401

{"code":401,"message":"JWT Token not found"}

This time let’s add the token in the header, like so

curl -X GET -H "Content-Type: application/json" -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE1NDgyODI4NjUsImV4cCI6MTU0ODI4NjQ2NSwicm9sZXMiOlsiUk9MRV9VU0VSIl0sInVzZXJuYW1lIjoicGF0YXRhIn0.SLB40jBqtbY7Ql5DI4L4rZBEcH5hXNqn9u0eVYOCtlE_vqa_1RYQzHHVy7iMbmP4CMjSKUiBlGzuIBGApRmD36CFKgdMdfXuqrHeEFB-BUsd-HezdLg6U762GnLbe4g4vHzg9XKZVCzRtpboGUKgVycIaMdfiZ1FvUkJeZvdS_5HgHW43LrSqntPlbEaNaEYv7mrkzTQNi1WiKwJaDCW5M0JgRkfbHoUYXMadFUxR4KSnaXRQAwxZnqqPBW4dRs97ho5A15XKpuxmEWemvhMs0XL9E8KyPNG7ZipLis4JGs3X9Mn-ov4RDCqYbShSNbtj_F2gcakXL97FF3myLn1U2XfIzuwxq9ZIJnGLtemKgPlxSB1uxX5ep9aYxppuXpwxY0vGr9MsOgyL3kkuMqeXvFDN46bY-3P8TLOqEuPrrlKYuRfMQv6Wrhdq0orl3eo7t83YCb_Z-Mf7yeDDGeJsftaj4pALJUw4Ovo6Kv_4gNcG3VQpkJr4XtnULAcO9O_OJLgVOBXoOc7lUQmokdvAGeltEBmYIZD_2KtGrTwS4rL55LMn3MawL4dKVIAg8aaYbPDCxkk1t3LdZyI5zSUiJvLaCrMM8ZhJ7eJ0rKod2-d_dZcYPzQ5RF_wD8spuw1pkT6r4hMyTJvGQUUDjN-3E-MkNBHT8Ku8Z8I7a65x5M" http://localhost:8000/api/user/1

This will return the user serialized, so success!

Conclusion

We’ve created a project from scratch and we’ve installed a User library, a JWT library, an API doc library and a CORS library. We’ve created a register endpoint, we’ve protected our api with firewalls and we’ve created a user endpoint with a voter to test authentication and authorization.

This is a great start to develop your API project and scale it up!

Happy coding! 🙂

Resources

Link to symfony4-api-jwt GitHub project.
Link to all commits.

Docker to set up the development environment.
LexikJWTAuthenticationBundle to authenticate users on our API side.
FOSUserBundle for User entities.
NelmioApiDocBundle for our API docs.
NelmioCorsBundle for Cross Origin.

Originally published at Joey’s blog.

--

--

Joey Masip Romeu
Joey Masip Romeu

Written by Joey Masip Romeu

Coder, Entrepreneur, Co-founder at SlowCode

Responses (12)