Using JWT has a few advantages when working with SPAs, but also a few security disadvantages. One of them is that (depending on the implementation) with only a refresh token you can initiate a new session. This is especially problematic if it's not cleared after logout which is the default when using the JWTRefreshTokenBundle with Symfony.

Fortunately it's not difficult to remove the refresh tokens on logout ether. We assume we're using the FOSUser bundle. The bundle contains a logout route which we will handle.

api_logout:
    path: '/api/logout'
    defaults: { _controller: FOS\UserBundle\Controller\SecurityController::logoutAction }

In our security.yml we configure the logout handler:

security:
  firewalls:
    api:
      pattern:   ^/api
      stateless: true
      ...
      logout:
        path: /api/logout
        success_handler: App\Service\Authentication\LogoutHandler
        handlers: [App\Service\Authentication\LogoutHandler]

In the logout handler is a handler and not a listener, because I also use it to adapt the success response.

<?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;

/**
 * This handler is run on logout.
 * It removes all refresh tokens for the authenticated user. This prevents the usage of old 
 * refresh tokens by an attacker. As there is no repository for refresh token we do it the 
 * good old way and use the database connection directy.
 */
final class LogoutHandler implements LogoutHandlerInterface
{
    /** @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 */
        /* @noinspection PhpUnhandledExceptionInspection */
        // 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()));
    }
}

This endpoint has to be triggered by the SPA of course!

This way even if the refresh token is stolen (via XSS for example) it's only valid until the user performs a logout.