Tokens & sessions
Access tokens are jwt-for-laravel RS256 user tokens; refresh tokens are rotating refresh-tokens-for-laravel families, one family per device session.
Access token claims
| Claim | Source |
|---|---|
iss, iat, nbf, exp, jti, scope | jwt |
aud | The guard’s JWT audience — the isolation boundary. |
sub | Account key. |
tv | Account::tokenVersion(). |
email, email_verified, permissions, extra | The guard’s ResolvesAccessTokenClaims (default: email per tokens.include_email, no permissions). |
sid | The refresh-token family id = the session id. |
amr | RFC 8176 methods accumulated through the login (omitted when empty). |
auth_time | When the first factor succeeded (kept across refreshes). |
grd | Guard name (diagnostics). |
Refresh and sessions
use RoundlyConsulting\Auth\Facades\Authentication;
$guard = Authentication::guard('users');
$context = $guard->contextFrom($request);
$current = $guard->tokenFrom($request); // jti, sid, auth_time, amr
$pair = $guard->refresh($refreshToken, $context); // rotates within the same session; the previous access token stops working
$guard->sessions($user, $current->sessionId); // Collection<SessionData>, current flagged
$guard->logout($user, $current); // this device
$guard->logoutSession($user, $sessionId); // one device
$revoked = $guard->logoutOthers($user, $current); // every other device
$revoked = $guard->logoutEverywhere($user); // tv++ and every sessionRefresh is throttled per IP. A successful refresh retires the session’s previous access token, so a session holds exactly one live access token and revoking the session kills every token it minted — a copied older one included. A token of another guard is unknown and not consumed; a disabled owner has its family revoked. Reusing a rotated token kills the family, fires RefreshTokenReuseReported and mails the owner a suspicious-session alert.
| Operation | Effect | Event |
|---|---|---|
| logout | Denies the current jti until it expires and revokes the session. | LoggedOut(Current) |
| logoutSession | Revokes one session; unknown or foreign ids are 404. | LoggedOut(Session) |
| logoutOthers | Revokes every other session without bumping tv (that would log the caller out); each revoked session’s one live access token is denied. | LoggedOut(Others) |
| logoutEverywhere | tv++ and every session revoked. | LoggedOut(Everywhere) + AccountTokensInvalidated |
| sessions.max_active | Oldest sessions revoked above the cap. | LoggedOut(Session) |
Invalidation
Each credential change applies the guard’s scope for its reason: none does nothing; others bumps tv, revokes every session and re-issues a fresh pair for the device that made the change; all bumps tv and revokes every session. Any scope other than none also kills the account’s active challenges and login one-time tokens.
| Trigger | Reason | Default scope |
|---|---|---|
| Change password | password_changed | others |
| Reset password | password_reset | all |
| Email change confirmed | email_changed | others |
| 2FA enabled / disabled / recovery codes regenerated | two_factor_changed | others |
| Passkey added / removed | passkey_changed | none |
| Account disabled | account_disabled | all (fixed) |
| Logout everywhere / incident | logout / security | all (fixed) |
Credential-change responses include tokens (or null). When non-null the client must swap to it immediately — its previous access token died with the change.
Configuration
'tokens' => [
'access_ttl' => null, // seconds; null → jwt.ttl
'refresh_ttl' => 2_592_000, // sliding, 30 days
'refresh_absolute_ttl' => 7_776_000, // 90 days; 0 = no cap
'claims_resolver' => DefaultClaimsResolver::class,
'include_email' => true,
],
'sessions' => [
'max_active' => null, // int|null — oldest sessions revoked above the cap
],
'invalidation' => [ // none|others|all (account_disabled is always all)
'password_changed' => 'others',
'password_reset' => 'all',
'email_changed' => 'others',
'two_factor_changed' => 'others',
'passkey_changed' => 'none',
],The jti denylist lives in cache: a flush revives revoked access tokens until they expire. Keep access_ttl short (15 minutes or less); the token version covers invalidations.
Custom claims
Bind your own ResolvesAccessTokenClaims to put permissions or tenant data into the access token:
use RoundlyConsulting\Auth\Contracts\Account;
use RoundlyConsulting\Auth\Contracts\ResolvesAccessTokenClaims;
use RoundlyConsulting\Auth\DataTransferObjects\AccessTokenClaims;
use RoundlyConsulting\Auth\Guards\GuardConfig;
// config: 'guards' => ['users' => ['tokens' => ['claims_resolver' => PermissionsClaims::class]]]
final class PermissionsClaims implements ResolvesAccessTokenClaims
{
public function resolve(Account $account, GuardConfig $guard): AccessTokenClaims
{
return new AccessTokenClaims(
email: $account->accountEmail(),
emailVerified: $account->hasVerifiedEmail(),
permissions: $account->permissions()->pluck('name')->all(),
extra: ['tenant' => $account->tenant_id],
);
}
}Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.