Token Lifecycle
Access tokens, refresh tokens, rotation, JWT claims, and how auth connects to authorization.
Token Lifecycle
Vortos uses signed JWT access tokens and refresh-token rotation.
| Token | Default TTL | Stored server-side | Purpose |
|---|---|---|---|
| Access token | 900 seconds | No | Authenticate API requests |
| Refresh token | 604800 seconds | Yes, by JTI | Issue the next token pair |
Access tokens
Access tokens are short-lived and sent on every API request:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Access tokens are not tracked in Redis. Their short TTL is the revocation mechanism.
Refresh tokens
Refresh tokens are longer-lived and used only to obtain a new access and refresh pair. Store them in an httpOnly cookie, not in localStorage.
Refresh tokens are tracked by their JTI. This enables explicit revocation on logout and one-use token rotation.
Token pair structure
{
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"access_token_expires_at": 1234567890,
"refresh_token_expires_at": 1234567890,
"token_type": "Bearer"
}Signing Algorithm
Vortos supports two JWT signing algorithms. Choose based on your architecture:
| Algorithm | Key type | Best for |
|---|---|---|
HS256 (default) | Single shared secret | Single-service apps — one secret signs and verifies |
RS256 | Private + public key pair | Multi-service apps — verifiers only need the public key and can never forge tokens |
HS256 (default)
JWT_SECRET is generated automatically by vortos:setup. Minimum 64 characters.
$config
->algorithm('HS256')
->secret($_ENV['JWT_SECRET'] ?? '');RS256
Generate a key pair once. vortos:auth:keys:generate creates its output directory if it doesn't exist:
# file mode (dev): writes jwt_<kid>_private.pem + jwt_<kid>_public.pem into --out
php bin/console vortos:auth:keys:generate --out=/run/secrets --kid=2026-06
# env mode (immutable image / prod): prints base64-PEM env lines to paste into your secret store
php bin/console vortos:auth:keys:generate --emit=env --kid=2026-06Prefer env-content keys for immutable images
On the immutable-image / deploy-in-image path there is no writable secrets directory to generate into, and the deploy one-shots take secrets as env only. Use --emit=env and supply the keys as base64-PEM env content (JWT_PRIVATE_KEY / JWT_PUBLIC_KEY) via .env.prod, reading them with ->privateKey(...) / ->publicKey(...) below. File paths are the local/dev fallback. First-deploy provisioning recognises either mode, so an env-content deploy is never told to regenerate keys.
Store PEM files outside the project root (e.g. /run/secrets/) — never commit them to git. Set the paths in .env:
JWT_PRIVATE_KEY_PATH=/run/secrets/jwt_private.pem
JWT_PUBLIC_KEY_PATH=/run/secrets/jwt_public.pem$config
->algorithm('RS256')
->privateKeyPath($_ENV['JWT_PRIVATE_KEY_PATH'] ?? '')
->publicKeyPath($_ENV['JWT_PUBLIC_KEY_PATH'] ?? '');privateKeyPath() and publicKeyPath() load the PEM file at container build time and throw a clear error if the file is missing or unreadable. You can also pass PEM content directly via ->privateKey($pem) / ->publicKey($pem) if your secrets manager injects the PEM as an environment variable.
Configure auth
use Vortos\Auth\DependencyInjection\VortosAuthConfig;
use Vortos\Auth\Storage\RedisTokenStorage;
return static function (VortosAuthConfig $config): void {
$config
->algorithm('HS256')
->secret($_ENV['JWT_SECRET'] ?? '')
->issuer('squaura')
->accessTokenTtl(900)
->refreshTokenTtl(604800)
->tokenStorage(RedisTokenStorage::class);
};Issue tokens at login
use Vortos\Auth\Identity\UserIdentity;
use Vortos\Authorization\Contract\AuthorizationVersionStoreInterface;
$identity = new UserIdentity(
id: (string) $user->getId(),
roles: $user->getRoles(),
);
$token = $this->jwtService->issue(
$identity,
$this->versions->versionForUser($user->getId()),
);
return new JsonResponse($token->toArray());authz_version is passed as the second argument — not as an identity attribute. See Authorization Version for why.
Custom identity attributes
Any extra data you want readable on authenticated requests goes in the third UserIdentity constructor argument. It is serialized into the JWT under the attrs claim and available via getAttribute() everywhere.
$identity = new UserIdentity(
id: $user->getId(),
roles: $user->getRoles(),
attributes: [
'organization_id' => $user->organizationId,
'plan' => $user->plan,
],
);After validation, anywhere in the request:
$user->getAttribute('organization_id'); // 'org-abc'
$user->getAttribute('plan'); // 'pro'
$user->getAttribute('missing', 'free'); // 'free'getClaims() controls what is serialized. By default UserIdentity::getClaims() returns all attributes. Override it in a custom identity class to exclude fields that should not be in the token.
Validating tokens (every request)
AuthMiddleware validates access tokens automatically on every main request. You usually do not call validate() directly unless building a custom flow.
// AuthMiddleware does this for you — returns ValidatedToken:
$result = $this->jwtService->validate($accessToken);
$identity = $result->identity; // UserIdentityInterface
$version = $result->authzVersion; // intvalidate() returns a ValidatedToken value object, not a UserIdentityInterface directly. AuthMiddleware stores $result->identity in the request context and $result->authzVersion in the authz version slot.
Refreshing tokens
$newToken = $this->jwtService->refresh($refreshToken, $identity, $authzVersion);Token rotation: the old refresh token is revoked before the new pair is issued. A stolen refresh token cannot be reused after legitimate use.
To refresh, first extract the user ID from the refresh token, load the current user and roles, then call refresh() with a fresh identity and the current authz version:
$userId = $this->jwtService->getUserIdFromRefreshToken($refreshToken);
$user = $this->users->get($userId);
$identity = new UserIdentity(
id: $userId,
roles: $user->roles(),
);
$newToken = $this->jwtService->refresh(
$refreshToken,
$identity,
$this->versions->versionForUser($userId),
);Logout
// Revoke all refresh tokens for this user (all devices)
$this->jwtService->revokeAll($identity->id());JWT payload structure
Access token:
{
"iss": "squaura",
"sub": "019d...",
"iat": 1234567890,
"exp": 1234568790,
"roles": ["ROLE_USER", "ROLE_ADMIN"],
"authz_version": 4,
"type": "access",
"attrs": {
"organization_id": "org-abc",
"plan": "pro"
}
}attrs is omitted entirely when there are no custom attributes.
Refresh token:
{
"iss": "squaura",
"sub": "019d...",
"iat": 1234567890,
"exp": 1235172690,
"jti": "019d...",
"type": "refresh"
}authz_version is a framework-owned top-level claim. It is passed explicitly to issue() and refresh() — not stored as an identity attribute. The authorization module reads it through its own internal path (RequestAuthzVersionProvider) independently of identity attributes.
Expected failures
JwtService throws framework exceptions for custom flows:
| Exception | Meaning |
|---|---|
TokenExpiredException | Token exp is in the past |
TokenInvalidException | Signature, payload, type, or format is invalid |
TokenRevokedException | Refresh token JTI is missing or revoked |
AuthMiddleware catches token errors and treats the request as anonymous. A protected route then returns 401.
Shorter access TTL means a smaller compromise window. Longer refresh TTL means less login friction.