Improve security when working with JWT and Symfony

Improve security when working with JWT and Symfony

Using JWT for authentication in a SPA with Symfony is very easy thanks to bundles like FOSUserBundle, LexikJWTAuthenticationBundle and JWTRefreshTokenBundle. Unfortunately they don't provide security against XSS attacks (which you can argue isn't really there purpose). In this post I want to show how we can enhance a Symfony application using those bundles to prevent XSS attacks. As it's quite a long one, I will divide it into the following parts:

  • Why applications with JWT are vulnerable against XSS
  • The separate problems we need to address
  • The solution for those problems
  • Necessary adaptations for controller tests
  • Adaptions for the frontend and tools like Postman

Why is the combination of JWT and XSS so relevant

JWT Basics

The promise of JWT is simple: In the frontend you perform a login action which returns a JWT (JSON Web Token). With this JWT you can then perform other actions on the server which require authentication by only providing the JWT. The authentication and expiration time is part of the JWT.

This has a few advantages:

  • You don't have to send a username and password on every request
  • You can easily scale to multiple servers on the backend without needing a central session storage (keyword stateless)

But also a major drawback:

As you're requesting and managing the JWT through your Javascript application, every other Javascript executed on the page can read the JWT and potentially send it to an attacker. This attacker then is able to send requests on the behalf of the user.

Making the problem worse

Usually the JWT is not used as a drop in replacement of a session with a long expiration date, but with a very short one, something like 15 minutes. Of course you don't want to have the user being logged out every 15 minutes. So you enhance the setup with a refresh token. Through an interceptor, every time a forbidden response is returned (because the JWT is expired), a new JWT is requested. This is done through the refresh token which itself has a much longer expiration date. Depending on the configuration, the expiration date is even extended every time a new JWT is requested through the refresh token.

And this refresh token is also readable by Javascript and therefore open for grabs by an attacker. To make matters even worse, the refresh tokens aren't deleted. Ever. So even if a new refresh token is generated through a new valid login of the user, the attacker still has a (potentially unlimited) refresh token to work with.

Answer: Don't use JWT?

Unfortunately that's the answer I found most often in articles about the topic.

But I don't really like that answer. So let's see what other options we have. And let's see what the underling problems are. The only problem we won't be able to address is that the JWT and refresh token are readable by Javascript (for obvious reasons).

Problems we have to address

  • A JWT is enough to authenticate an API request
  • A JWTs are still valid when a user performs a logout
  • A refresh token stolen ones can be refreshed itself
  • A refresh token is still valid when a user performs a logout
  • Different refresh tokens for the same user are valid

The solution

As the JWT is bound to the refresh token, the refresh token is our single point of failure and it makes sense to improve the security there.
For maximum security we will never allow two sessions with the same user at once. Which means if the user is logged in on one device and then logs in on another device, his session in the first one will be killed.

1) Remove refresh token after usage

Since version 0.7.0, the refresh token bundle contains a new option single_use which deletes a refresh token after usage and issues a new one. This means once an attacker steals a refresh token, he only has until the next usage of the user to use the refresh token. Afterwards is already useless. So let's enable this in the gesdinet_jwt_refresh_token.yml.

gesdinet_jwt_refresh_token:
    single_use: true

This obviously makes the setting ttl_update: true useless.

2) Remove refresh tokens on logout

After logout there is no need for a refresh token to be around so let's make sure that they are removed.
For this we add a custom logout handler to our firewall which is usually called api in my applications. This means I'm using the default logout functionality from Symfony, but extending the handling.

security:
  firewalls:
    api:
      ...
      logout:
        path: /api/logout
        handlers: [App\Service\Authentication\LogoutHandler]

Within the handler we simply inject the database connection and remove the tokens. The repository from the bundle doesn't support deleting all refresh tokens for one user, but you could also extend the RefreshTokenManager implementation, add a repository method for it and inject this here instead.

<?php

declare(strict_types=1);

namespace App\Service\Authentication;

use App\Entity\User;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\Logout\LogoutHandlerInterface;
use Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface;

final class LogoutHandler implements LogoutHandlerInterface, LogoutSuccessHandlerInterface
{
    /** @var Connection */
    private $databaseConnection;

    public function __construct(Connection $databaseConnection)
    {
        $this->databaseConnection = $databaseConnection;
    }

    public function logout(Request $request, Response $response, TokenInterface $token): void
    {
        $authenticatedUser = $token->getUser();

        if (null === $authenticatedUser) {
            return;
        }

        /* @var User $authenticatedUser */
        // Possible exception should not be caught, as we need to become aware that something broke here
        $this->databaseConnection->exec(sprintf('
            DELETE FROM refresh_tokens
            WHERE username = "%s"
        ', $authenticatedUser->getUsername()));
    }
}

As long as the JWT is enough for the authentication, it's still our single point of failure. And even if our refresh token is now less valuable, it's still enough to generate a new JWT. So what we need is an additional component which is not readable via Javascript. And that's exactly what a http-only cookie is. It can be only set and read by the server and will automatically be send by the browser with every request.

And the best thing: We can build the cookie on top of the refresh token of the user. This means whenever the refresh token is removed, the cookie is also invalided.

The value should be based on the user, the current valid refresh token and a random salt. This way it's still not dependent on the server session but on the values in the database. We would also have the option to force a global logout through changing the salt used on the server. A small method for this could look like this:

private function generateSecurityCookieHash(User $user, RefreshTokenInterface $refreshToken): string
{
    return md5(
        sprintf('%s%s%s', $user->getId(), $refreshToken->getRefreshToken(), $this->securityCookieSalt)
    );
}

As we're going to be needing this and a few additional methods, we will create a central Service called AuthenticationService.

In it we will create the cookie depending on the refresh token of the authenticated user. The full service will look like this:

<?php

declare(strict_types=1);

namespace App\Service\Authentication;

use App\Entity\User;
use Gesdinet\JWTRefreshTokenBundle\Model\RefreshTokenInterface;
use Gesdinet\JWTRefreshTokenBundle\Model\RefreshTokenManagerInterface;
use Symfony\Component\HttpFoundation\Cookie;

final class AuthenticationService
{
    public const SECURITY_COOKIE_NAME = 'security';

    /** @var RefreshTokenManagerInterface */
    private $refreshTokenManager;

    /** @var string */
    private $securityCookieSalt;

    public function __construct(
        RefreshTokenManagerInterface $refreshTokenManager,
        string $securityCookieSalt
    ) {
        $this->refreshTokenManager = $refreshTokenManager;
        $this->securityCookieSalt = $securityCookieSalt;
    }

    public function createSecurityCookie(User $user): ?Cookie
    {
        /**
         * This might happen if a user is logged in on one computer and logs out on another computer. On refresh of the
         * session on the first computer, the refresh tokens will be removed and the user should be logged out.
         */
        $refreshToken = $this->refreshTokenManager->getLastFromUsername($user->getEmail());
        if (null === $refreshToken) {
            return null;
        }

        $cookieHash = $this->generateSecurityCookieHash($user, $refreshToken);

        return new Cookie(
            self::SECURITY_COOKIE_NAME,
            $cookieHash,
            $refreshToken->getValid(),
            null,
            null,
            true,
            true
        );
    }

    public function isSecurityHashValid(string $securityHash, User $user): bool
    {
        /**
         * This might happen if a user is logged in on one computer and logs out on another computer. On refresh of the
         * session on the first computer, the refresh tokens will be removed and the user should be logged out.
         */
        $refreshToken = $this->refreshTokenManager->getLastFromUsername($user->getEmail());
        if (null === $refreshToken) {
            return false;
        }

        return $securityHash === $this->generateSecurityCookieHash($user, $refreshToken);
    }

    private function generateSecurityCookieHash(User $user, RefreshTokenInterface $refreshToken): string
    {
        return md5(
            sprintf('%s%s%s', $user->getId(), $refreshToken->getRefreshToken(), $this->securityCookieSalt)
        );
    }
}

We use the same expiration date for the cookie as we have for the refresh token and mark it as http-only.

To set the cookie, we add a login success handler and use our authentication service there:

security:
  firewalls:
    login:
      form_login:
        ...
        check_path: /api/login
        success_handler: App\Service\Authentication\LoginSuccessHandler
<?php

declare(strict_types=1);

namespace App\Service\Authentication;

use App\Entity\User;
use Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationSuccessHandler as LexikAuthenticationSuccessHandler;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;

final class LoginSuccessHandler extends LexikAuthenticationSuccessHandler
{
    /** @var AuthenticationService */
    private $authenticationService;

    public function __construct(
        JWTTokenManagerInterface $jwtManager,
        EventDispatcherInterface $dispatcher,
        AuthenticationService $authenticationService
    ) {
        parent::__construct($jwtManager, $dispatcher);

        $this->authenticationService = $authenticationService;
    }

    /**
     * This is called when an interactive authentication attempt succeeds.
     * This is called by authentication listeners inheriting from AbstractAuthenticationListener.
     */
    public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response
    {
        /** @var User $user */
        $user = $token->getUser();
        
        $response = $this->handleAuthenticationSuccess($user);

        // Security cookie
        /** @var Cookie $securityCookie */
        $securityCookie = $this->authenticationService->createSecurityCookie($user);
        $response->headers->setCookie($securityCookie);

        return $response;
    }
}

Now we will get a security cookie which we will be send on every request and is not accessible by Javascript. But it's not evaluated at the moment, so let's do that.

Now comes the part I'm still not happy with, because I didn't find a nice entry point. The guard used when using the JWT bundle is the JWTTokenAuthenticator. The easiest way to attach the cookie would be a listener on the response, but unfortunately there is no way to get the request there. And every way where I have access to the user, we don't have access to the response. So the only way I found was to to build my own authenticator, extend the JWTTokenAuthenticator, use the same onAuthenticationSuccess method and calling the parent method to have the same behaviour as before just with the added cookie verification. The full authenticator will be configured as the authenticator in the security.yml and looks like this.

security:
  firewalls:
    api:
      pattern:   ^/api
      stateless: true
      ...
      guard:
        authenticators:
          - App\Service\Authentication\JWTAndSecurityCookieAuthenticator
<?php

declare(strict_types=1);

namespace App\Service\Authentication;

use App\Entity\User;
use Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;

final class JWTAndSecurityCookieAuthenticator extends JWTTokenAuthenticator
{
    /** @var AuthenticationService */
    private $authenticationService;

    public function __construct(
        JWTTokenManagerInterface $jwtManager,
        EventDispatcherInterface $dispatcher,
        TokenExtractorInterface $tokenExtractor,
        AuthenticationService $authenticationService
    ) {
        parent::__construct($jwtManager, $dispatcher, $tokenExtractor);

        $this->authenticationService = $authenticationService;
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
    {
        if ('api_logout' === $request->get('_route')) {
            return null;
        }

        /** @var User $user */
        $user = $token->getUser();

        $securityCookieSecret = $request->cookies->get(AuthenticationService::SECURITY_COOKIE_NAME);

        if (null === $securityCookieSecret
            || !$this->authenticationService->isSecurityHashValid($securityCookieSecret, $user)) {
            return $this->onAuthenticationFailure($request, new AuthenticationException('Wrong security cookie'));
        }

        return null;
    }
}

We skip the handler for the logout route, as the refresh token against we would validate the cookie, was deleted right before the authenticator is triggered.

Let me know if you are aware of a nicer option to integrate this functionality. I will write the maintainers of the JWT bundle and see if we can figure out an event listener I could use here.

Validate the refresh token request

The request to refresh a token is not handled by the firewall as it can't be authenticated as you only request a new token if the old one expired. So it has to work without authentication. But we still can validate the cookie here too. Although the way to do it is even worse than the authenticator.

We have a similar issue as with the JWT bundle in that there are events we could listen to, but never ones with access to request and response at the same time. Therefore the only solution I found was to copy the refresh token service used as endpoint for the refresh token route and extend it. Meaning instead of this route which is the default:

api_refresh_token:
  path: '/api/token/refresh'
  defaults: { _controller: gesdinet.jwtrefreshtoken:refresh }
  methods: [POST]

We use the following one:

api_refresh_token:
  path: '/api/token/refresh'
  defaults: { _controller: App\Service\Authentication\RefreshTokenSecurityCookieService:refresh }
  methods: [POST]

The full copy is annotated with the spaces where I put the cookie validation and cookie creation after the new refresh token is added.

<?php

declare(strict_types=1);

namespace App\Service\Authentication;

use App\Entity\User;
use Gesdinet\JWTRefreshTokenBundle\Event\RefreshEvent;
use Gesdinet\JWTRefreshTokenBundle\Model\RefreshTokenManagerInterface;
use Gesdinet\JWTRefreshTokenBundle\Security\Authenticator\RefreshTokenAuthenticator;
use Gesdinet\JWTRefreshTokenBundle\Security\Provider\RefreshTokenProvider;
use Gesdinet\JWTRefreshTokenBundle\Service\RefreshToken;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface as ContractsEventDispatcherInterface;

final class RefreshTokenSecurityCookieService
{
    /** @var RefreshTokenAuthenticator */
    private $authenticator;

    /** @var RefreshTokenProvider */
    private $provider;

    /** @var AuthenticationSuccessHandlerInterface */
    private $successHandler;

    /** @var AuthenticationFailureHandlerInterface */
    private $failureHandler;

    /** @var RefreshTokenManagerInterface */
    private $refreshTokenManager;

    /** @var int */
    private $ttl;

    /** @var string */
    private $providerKey;

    /** @var bool */
    private $ttlUpdate;

    /** @var EventDispatcherInterface */
    private $eventDispatcher;

    /** @var AuthenticationService */
    private $authenticationService;

    public function __construct(
        RefreshTokenAuthenticator $authenticator,
        RefreshTokenProvider $provider,
        AuthenticationSuccessHandlerInterface $successHandler,
        AuthenticationFailureHandlerInterface $failureHandler,
        RefreshTokenManagerInterface $refreshTokenManager,
        int $ttl,
        string $providerKey,
        bool $ttlUpdate,
        EventDispatcherInterface $eventDispatcher,
        AuthenticationService $authenticationService
    ) {
        $this->authenticator = $authenticator;
        $this->provider = $provider;
        $this->successHandler = $successHandler;
        $this->failureHandler = $failureHandler;
        $this->refreshTokenManager = $refreshTokenManager;
        $this->ttl = $ttl;
        $this->providerKey = $providerKey;
        $this->ttlUpdate = $ttlUpdate;
        $this->eventDispatcher = $eventDispatcher;
        $this->authenticationService = $authenticationService;
    }

    public function refresh(Request $request): Response
    {
        // - Start copy of RefreshToken service
        try {
            /** @var User $user */
            $user = $this->authenticator->getUser(
                $this->authenticator->getCredentials($request),
                $this->provider
            );

            $postAuthenticationToken = $this->authenticator->createAuthenticatedToken($user, $this->providerKey);
        } catch (AuthenticationException $e) {
            return $this->failureHandler->onAuthenticationFailure($request, $e);
        }

        $refreshToken = $this->refreshTokenManager->get($this->authenticator->getCredentials($request));

        if (null === $refreshToken || !$refreshToken->isValid()) {
            return $this->failureHandler->onAuthenticationFailure($request, new AuthenticationException(
                    sprintf('Refresh token "%s" is invalid.', $refreshToken)
                )
            );
        }
        // - Stop copy of RefreshToken service

        // Check security cookie
        $securityCookieSecret = $request->cookies->get(AuthenticationService::SECURITY_COOKIE_NAME);

        if (null === $securityCookieSecret
            || !$this->authenticationService->isSecurityHashValid($securityCookieSecret, $user)) {
            return $this->failureHandler->onAuthenticationFailure($request, new AuthenticationException(
                    'Invalid security cookie.'
                )
            );
        }

        // - Start copy of RefreshToken service

        if ($this->ttlUpdate) {
            $expirationDate = new \DateTime();
            $expirationDate->modify(sprintf('+%d seconds', $this->ttl));
            $refreshToken->setValid($expirationDate);

            $this->refreshTokenManager->save($refreshToken);
        }

        if ($this->eventDispatcher instanceof ContractsEventDispatcherInterface) {
            $this->eventDispatcher->dispatch(new RefreshEvent($refreshToken, $postAuthenticationToken), 'gesdinet.refresh_token');
        } else {
            $this->eventDispatcher->dispatch('gesdinet.refresh_token', new RefreshEvent($refreshToken, $postAuthenticationToken));
        }

        $response = $this->successHandler->onAuthenticationSuccess($request, $postAuthenticationToken);
        // - Stop copy of RefreshToken service

        // Add new security cookie to response (old one isn't valid any more with the new refresh token)
        $securityCookie = $this->authenticationService->createSecurityCookie($user);
        if (null !== $securityCookie) {
            $response->headers->setCookie($securityCookie);
        } else {
            $response->headers->removeCookie(AuthenticationService::SECURITY_COOKIE_NAME);
        }

        return $response;
    }
}

This way when a new JWT is requested, the refresh token and the cookie is validated. A new cookie is issued here too, because whenever the JWT is requested, a new refresh token is issued and therefore the old cookie was invalidated.

Usage in tests

Depending on your testing environment, you will need to adapt the handling of your controller tests. If bypass the usual authentication and use a static basic authentication, then you will already have a different authentication schema in your tests. Depending on that you might need to remove certain listeners and handlers too.

In the projects I'm working on, I try to have the controller tests as close to the live system as possible. Which means I'm doing separate logins for tests. This of course increases the test runtime by a lot, as the login part is very intensive by design. To reduce the performance impact, I store the combination of JWT and cookie in a cache, so that the same user doesn't have to login twice throughout the test run.

The easiest solution is to create a new test case ControllerTestCase. It contains a method getAuthorizationForUser to get an authorization for a specific user. It also caches the authorization and returns the authorization from the cache if there is already one for the requested user.
The second central method sendRequest is a wrapper around the client object which adds the header from the JWT and cookie to the request.

<?php

/** @noinspection PhpUnhandledExceptionInspection */
/** @noinspection ForgottenDebugOutputInspection */
declare(strict_types=1);

namespace App\Test;

use App\Service\Authentication\AuthenticationService;
use App\Test\ValueObject\Authorization;
use Gesdinet\JWTRefreshTokenBundle\Doctrine\RefreshTokenManager;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\BrowserKit\Cookie;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class ControllerTestCase extends WebTestCase
{
    /** @var KernelBrowser */
    protected $client;

    /** @var RefreshTokenManager */
    private $refreshTokenManager;

    public static function setupBeforeClass(): void
    {
        self::bootKernel();
    }

    protected function setUp(): void
    {
        parent::setUp();

        $this->client = self::createClient();
        $this->refreshTokenManager = $this->getContainer()->get('gesdinet.jwtrefreshtoken.refresh_token_manager');
    }

    protected function getContainer(): ContainerInterface
    {
        return self::$container;
    }

    protected function getAuthorizationForUser(string $userEmail, string $plainUserPassword): Authorization
    {
        if ($authorization = AuthorizationCache::getAuthorization($userEmail)) {
            return $authorization;
        }

        $authorization = $this->getAuthorization($userEmail, $plainUserPassword);

        if (null === $authorization) {
            echo 'Specific login failed!';
            exit;
        }

        AuthorizationCache::addAuthorization($userEmail, $authorization);

        return $authorization;
    }

    protected function getAuthorization(string $username, string $password): ?Authorization
    {
        $this->client->request(
            Request::METHOD_POST,
            '/api/login',
            [
                '_username' => $username,
                '_password' => $password,
            ]
        );

        $response = $this->client->getResponse();
        $data = json_decode($response->getContent(), true);

        // Get JWT
        /** @var string $token */
        $token = $data['token'] ?? null;

        // Get refresh token
        $refreshToken = $this->refreshTokenManager->getLastFromUsername($username);

        // Get security cookie
        $securityCookie = null;
        foreach ($response->headers->getCookies() as $cookie) {
            if (AuthenticationService::SECURITY_COOKIE_NAME === $cookie->getName()) {
                $securityCookie = $cookie;
            }
        }

        if (null === $token || null === $refreshToken || null === $securityCookie) {
            return null;
        }

        return new Authorization(
            $token,
            $refreshToken,
            new Cookie(
                $securityCookie->getName(),
                $securityCookie->getValue(),
                (string) $securityCookie->getExpiresTime()
            )
        );
    }

    private function generateHeaderFromToken(string $token): array
    {
        return [
            'HTTP_Authorization' => sprintf('%s %s', 'Bearer', $token),
        ];
    }

    protected function sendRequest(
        string $method,
        string $url,
        array $parameters,
        ?Authorization $authorization,
        array $content = []
    ): Response {
        $headers = [];
        if ($authorization) {
            // Add JWT to headers
            $headers = $this->generateHeaderFromToken($authorization->getToken());
            // Add refresh token to database as it might be removed through test helper
            if (null === $this->refreshTokenManager->get($authorization->getRefreshToken()->getRefreshToken())) {
                $this->refreshTokenManager->save($authorization->getRefreshToken(), true);
            }
            // Add cookie
            $this->client->getCookieJar()->clear();
            $this->client->getCookieJar()->set($authorization->getSecurityCookie());
        }

        $this->client->request(
            $method,
            $url,
            $parameters,
            [],
            $headers,
            json_encode($content)
        );

        $response = $this->client->getResponse();

        /* @var JsonResponse $response */

        return $response;
    }
}

If you wondering why I store the refresh token on every request, that's because I have a listener in my test suite which rolls back all database transactions after every test to prevent side effects of those tests. If you don't use anything like this, you don't need to store it as the refresh token is stored on the login process.

The authorization cache is a simple static singleton with a value object to hold the combination of user, JWT and cookie.

<?php

declare(strict_types=1);

namespace App\Test;

use App\Test\ValueObject\Authorization;

final class AuthorizationCache
{
    /** @var Authorization[] */
    private static $authorizations = [];

    public static function addAuthorization(string $email, Authorization $authorization): void
    {
        self::$authorizations[$email] = $authorization;
    }

    public static function getAuthorization(string $email): ?Authorization
    {
        if (array_key_exists($email, self::$authorizations)) {
            return self::$authorizations[$email];
        }

        return null;
    }
}
<?php

declare(strict_types=1);

namespace App\Test\ValueObject;

use Gesdinet\JWTRefreshTokenBundle\Model\RefreshTokenInterface;
use Symfony\Component\BrowserKit\Cookie;

final class Authorization
{
    /** @var string */
    private $token;

    /** @var RefreshTokenInterface */
    private $refreshToken;

    /** @var Cookie */
    private $securityCookie;

    public function __construct(string $token, RefreshTokenInterface $refreshToken, Cookie $securityCookie)
    {
        $this->token = $token;
        $this->refreshToken = $refreshToken;
        $this->securityCookie = $securityCookie;
    }

    public function getToken(): string
    {
        return $this->token;
    }

    public function getRefreshToken(): RefreshTokenInterface
    {
        return $this->refreshToken;
    }

    public function getSecurityCookie(): Cookie
    {
        return $this->securityCookie;
    }
}

Frontend adaptations and tools like Postman

The nice thing about the http-only cookie: You can't touch it with the Javascript frontend and therefore you also don't have to do anything here. It will automatically be attached to every request by the browser. This even includes newer technologies like WebSockets.

The same goes for tools that simulate a browser like Postman. When triggering the login, you will see the secret cookie in the response and it will be directly attached to all other endpoints with the same domain.

Set-Cookie with security=...

When chaining multiple API calls through a micro service architecture, you just have to make sure to not just forward the JWT (Bearer header) but also the cookie.

Final remarks

This hole setup is a way to add an additional security layer on top of your JWT setup. Every setup has it's disadvantages. Classic session cookies are more difficult to scale and are prone to Cross-Site-Request-Forgery attacks. A JWT prevents those by design but is more vulnerable to XSS attacks. With this setup we use the best of both worlds and enhance our security greatly.

Let me know if you know a way to cleanup the ugly parts (where I needed to overwrite or simply copy existing code of the bundles). And let me know if I'm missing something and can improve the security somewhere.