Vortos
Authentication

Authorization Version

How JWT authz_version prevents old access tokens from keeping revoked roles and permissions.

Authorization Version

authz_version connects authentication to authorization.

Access tokens are stateless. Without a version check, a user whose role was removed could keep using the old access token until it expires. Vortos solves this by embedding the user's authorization version in each access token.

The claim

authz_version is a first-class JWT claim owned entirely by the authorization module. It is passed explicitly to issue() and refresh() — it is not an identity attribute and does not go through getClaims().

{
  "sub": "user-123",
  "roles": ["ROLE_COACH"],
  "authz_version": 4,
  "type": "access"
}

When JwtService::validate() decodes the token it returns a ValidatedToken value object carrying authzVersion as a typed int field, separate from the identity. AuthMiddleware stores it in the request-scoped context where PolicyEngine reads it through RequestAuthzVersionProvider.

Runtime version

The authorization module stores the current runtime version in AuthorizationVersionStoreInterface.

With Redis available, this is RedisAuthorizationVersionStore.

When a user role is assigned or removed, UserRoleAdminService increments the target user's runtime version.

Decision behavior

During an authorization decision:

token authz_version < runtime authz version
  -> deny with stale_token

The user must refresh or reauthenticate to get a token with the latest version.

Issue tokens correctly

Pass the current runtime version as the second argument to issue() and refresh():

use Vortos\Auth\Identity\UserIdentity;
use Vortos\Authorization\Contract\AuthorizationVersionStoreInterface;

final class LoginController
{
    public function __construct(
        private JwtService $jwt,
        private AuthorizationVersionStoreInterface $versions,
    ) {}

    public function __invoke(LoginRequest $request): JsonResponse
    {
        $user = $this->users->verifyCredentials($request);

        $identity = new UserIdentity(
            id: $user->id(),
            roles: $user->roles(),
        );

        return new JsonResponse(
            $this->jwt->issue(
                $identity,
                $this->versions->versionForUser($user->id()),
            )->toArray()
        );
    }
}

On refresh:

$userId   = $this->jwt->getUserIdFromRefreshToken($refreshToken);
$user     = $this->users->get($userId);
$identity = new UserIdentity($userId, $user->roles());

$newToken = $this->jwt->refresh(
    $refreshToken,
    $identity,
    $this->versions->versionForUser($userId),
);

Configure the check

Enabled by default:

config/authorization.php
$config->authzVersionCheck(true);

Disable only for tests or local experiments:

$config->authzVersionCheck(false);

What changes increment the version

Built-in behavior:

ChangeVersion incremented
Assign user roleYes
Remove user roleYes
Grant permission to roleNo, role generation cache handles this
Revoke permission from roleNo, role generation cache handles this

Role-permission changes affect all users with that role, so Redis role generations invalidate resolved-permission caches. User-role changes affect one user, so that user's version is incremented.

On this page