Vortos
Authentication

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.

TokenDefault TTLStored server-sidePurpose
Access token900 secondsNoAuthenticate API requests
Refresh token604800 secondsYes, by JTIIssue 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:

AlgorithmKey typeBest for
HS256 (default)Single shared secretSingle-service apps — one secret signs and verifies
RS256Private + public key pairMulti-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/auth.php
$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-06

Prefer 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/auth.php
$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

config/auth.php
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;  // int

validate() 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:

ExceptionMeaning
TokenExpiredExceptionToken exp is in the past
TokenInvalidExceptionSignature, payload, type, or format is invalid
TokenRevokedExceptionRefresh 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.

On this page