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_tokenThe 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->authzVersionCheck(true);Disable only for tests or local experiments:
$config->authzVersionCheck(false);What changes increment the version
Built-in behavior:
| Change | Version incremented |
|---|---|
| Assign user role | Yes |
| Remove user role | Yes |
| Grant permission to role | No, role generation cache handles this |
| Revoke permission from role | No, 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.