π Migration Guide¶
This document summarises breaking changes between major versions and how to upgrade. For the full per-release changelog, see CHANGELOG.md.
π Index¶
- Upgrading to this release β token-version revocation & hashed ephemeral tokens
- Upgrading to the next release (
Unreleased) - Upgrading to v5.x
- Upgrading to v4.x β
Config\Authsplit - General upgrade checklist
Upgrading to this release β token-version revocation & hashed ephemeral tokens¶
This release ships two additive migrations and several behavioural changes that upgraders must be aware of. There are no destructive schema changes β the schema deltas are the additive users.token_version column and the new auth_webauthn_credentials table.
Required steps¶
- Run migrations β two new migrations ship with this release:
| Migration | Adds |
|---|---|
2026-05-08-000001_add_jwt_token_version_to_users |
users.token_version (int, NOT NULL, default 0) |
2026-06-03-000001_create_webauthn_credentials |
auth_webauthn_credentials β the dedicated WebAuthn / passkey credential table |
2026-05-08-000001 backs JWT access-token revocation (see below); its down() simply drops the column. 2026-06-03-000001 creates the passkey table; it is only used when AuthSecurity::$webauthnEnabled is true, but the table is created regardless so enabling the flag later requires no further migration. See WebAuthn / Passkeys β Storage.
- Schedule
php spark auth:purgeβAuthSecurity::$rememberMePurgeChancenow defaults to0(was20). Expired remember-me tokens are now rejected at validation time regardless, so the old probabilistic on-login purge is pure table maintenance. Move that maintenance to a scheduled command instead:
# Run on a schedule (cron / daycry/jobs)
php spark auth:purge # purges expired remember-me tokens + terminated device sessions older than 30 days
php spark auth:purge --days 7 # use a 7-day retention window for terminated device sessions
auth:purge (command group Auth) removes expired rows from auth_remember_tokens and terminated auth_device_sessions rows older than --days (default 30). If you want to keep inline purging, set AuthSecurity::$rememberMePurgeChance back to a non-zero value β but a scheduled command is recommended.
Behavioural changes you must know about¶
| Change | What it means for upgraders |
|---|---|
| Magic-link / password-reset tokens are now hashed at rest | TokenEmailSender stores hash('sha256', $token) in auth_users_identities.secret; the raw token is e-mailed and UserIdentityModel::getIdentityBySecret() hashes the looked-up value before matching. This is a storage-format change for those ephemeral identity types only β not a schema change. Any unconsumed magic-link / password-reset tokens issued before the upgrade become invalid. Users simply request a new link. |
auth() throws on unknown methods |
Daycry\Auth\Auth::__call() now throws \BadMethodCallException when the resolved authenticator has no such method (previously returned null silently). A Session-only method (e.g. startLogin, getPendingUser, remember) called while a stateless authenticator is active will now surface immediately. Audit any code that called those methods through auth() regardless of the active authenticator. |
| Access-token / JWT login logging is fingerprinted | The login-attempt log (auth_logins.identifier) now stores a non-reversible hash('sha256', $token) fingerprint for AccessToken and JWT credentials β never the raw bearer token. Session login still logs the email/username identifier (not a secret). Any tooling that parsed raw tokens out of the login log must be updated. |
JWT access-token revocation (new token_version)¶
The new users.token_version column powers stateless, denylist-free revocation of JWT access tokens:
JwtControllernow mints the access-token payload as{uid, tv}, wheretvis the user's currenttoken_version. Legacy scalar payloads (a bare user id) are still accepted β thetvcheck is skipped for them.- The
JWTauthenticator'scheck()rejects a token whose embeddedtvdoes not match the user's currenttoken_version, returninglang('Auth.revokedToken'). User::revokeIssuedTokens()bumpstoken_versionatomically, invalidating all outstanding access tokens for that user. It is called automatically byBannable::ban()andServices\PasswordChangeRecorder::record()(on password reset/change). Call it directly for a "log out everywhere" action:
JwtControllerroutes refresh / logout / issue throughservice('jwtTokenRepository'): refresh is now one-time-use rotation, and logout soft-revokes the refresh token.
Other behaviour now enforced automatically (no action needed)¶
- Remember-me expiry & theft detection β
RememberMe::checkRememberMeToken()enforces expiry at validation time (an expired cookie can no longer authenticate). On theft detection (selector matches but validator does not) it purges all of the user's remember-me tokens, writes thelogin.suspiciousaudit event, and firesEvents::trigger('remember-me-theft', $userId, $selector). - TOTP lockout & anti-replay β TOTP / backup-code verification now goes through the same per-user lockout as password login (
UserLockoutManager), and a TOTP code is single-use within its acceptance window (a code at or below the last consumed time-step is rejected). - Device-session revocation actually invalidates the live session β when
Auth::$sessionConfig['trackDeviceSessions']istrue, every authenticated request verifies the current PHP session maps to a non-terminatedauth_device_sessionsrow (DeviceSessionModel::isSessionActive()). A remotely-revoked or concurrent-limit-evicted session is forced to re-authenticate. (Previously "revoke" only flipped a DB column and the cookie kept working.)
Optional β opt-in to new behaviour¶
| Option | Default | Meaning |
|---|---|---|
AuthSecurity::$activeDateThrottle |
60 |
Minimum seconds between users.last_active writes on the authenticated hot path. 0 = write every request (legacy behaviour). |
AuthSecurity::$gateFallbackToRbac |
true |
A Gate ability whose name contains a scope (e.g. users.edit) with no registered closure/policy falls back to User::can(). Set false to keep Gate and RBAC fully independent. |
AuthOAuth provider option 'allowUnverifiedEmailLink' |
unset (false) |
Per provider in $providers. When a social account's e-mail matches an existing local (password) account, auto-linking only happens if the provider asserts the e-mail is verified. Providers that cannot assert verification (Facebook, GitHub) refuse the merge unless this is true; refusal throws AuthenticationException with lang('Auth.oauthEmailUnverified'). |
New explicit OAuth account-linking flow¶
A logged-in user can deliberately link an additional provider via:
| Method | HTTP route | Route name | Controller |
|---|---|---|---|
GET |
oauth/link/(:segment) |
oauth-link |
OauthController::link($provider) |
The route requires an authenticated user, stashes the current user (session key oauth_link_user_id), and the shared callback links the provider to the current user β no e-mail merge and no verified-email requirement, because the user is authenticated and acting deliberately. Linking a social account already bound to a different local user is refused with lang('Auth.oauthAlreadyLinked').
Filter argument changes¶
| Filter | New argument form | Effect |
|---|---|---|
rates |
rates:<limit>,<period> |
Overrides the global limit/time for that route. <period> is a number of seconds or a named unit: SECOND, MINUTE, HOUR, DAY, WEEK. A configured endpoint DB row still overrides. (The registered alias is rates.) |
password-confirm |
password-confirm:<seconds> |
Requires a password confirmation no older than <seconds> for that route, regardless of the global AuthSecurity::$passwordConfirmationLifetime ("sudo mode" for the most sensitive routes). |
gate |
gate:users.edit |
Honors the Gate β RBAC fallback ($gateFallbackToRbac), so gate:users.edit and permission:users.edit can share semantics. |
Upgrading to the next release (Unreleased)¶
The Unreleased section in CHANGELOG.md adds 13 features and a handful of internal improvements. No breaking changes β all new behaviour is opt-in.
Required steps¶
- Run migrations β six new migrations ship with this release:
| Migration | Adds |
|---|---|
2026-05-07-000001_add_identities_user_type_revoked_index |
Composite index on auth_identities(user_id, type, revoked_at) |
2026-05-07-000002_create_audit_logs_table |
auth_audit_logs |
2026-05-07-000003_create_totp_backup_codes_table |
auth_totp_backup_codes |
2026-05-07-000004_add_trusted_until_to_device_sessions |
auth_device_sessions.trusted_until |
2026-05-07-000005_create_password_history_table |
auth_password_history |
2026-05-07-000006_add_password_changed_at_to_users |
users.password_changed_at |
- Rename test helpers (deprecated, BC kept) β if your tests use the typo'd helpers, the corrected names are now available; the old ones still work as deprecated aliases.
| Before (deprecated) | After |
|---|---|
$this->inkectMockAttributes(...) |
$this->injectMockAttributes(...) |
$this->inkectMockAttributesSecurity(...) |
$this->injectMockAttributesSecurity(...) |
$this->inkectMockAttributesOAuth(...) |
$this->injectMockAttributesOAuth(...) |
The deprecated names will be removed in v6.
Optional β opt-in to new features¶
Each of these defaults to "off / unchanged" β adopt only what fits your security posture.
| Feature | Where to enable | Reference |
|---|---|---|
Throttle access-token last_used_at writes |
AuthSecurity::$tokenLastUsedThrottle = 60 |
Authentication β Access Token |
| Concurrent session limit | Auth::$maxConcurrentSessions = 5 |
Device Sessions β Concurrent Limit |
| Trusted devices (2FA bypass) | AuthSecurity::$trustedDeviceLifetime = 30 * DAY |
TOTP β Trust This Device |
| TOTP backup codes | (automatic on TOTP confirmation) | TOTP β Backup Codes |
| Compromised-password recheck on login | AuthSecurity::$recheckPwnedOnLogin = true |
Audit & Compliance |
| Suspicious login alerts | AuthSecurity::$suspiciousLoginAlerts = true + listener |
Audit & Compliance |
| Password history (no reuse) | AuthSecurity::$passwordHistorySize = 5 + add HistoryValidator |
Audit & Compliance |
| Password rotation policy | AuthSecurity::$passwordMaxAge = 90 * DAY + apply password-age filter |
Audit & Compliance |
| API token scope enforcement | Apply token-scope: filter on routes |
Filters β Token Scope |
| Login activity feed | Wire UserSecurityController::loginActivity route |
Controllers β loginActivity() |
What runs automatically (no action needed)¶
- The audit log starts capturing events immediately for: TOTP enable/disable, password changes, lockouts, group/permission grants & revokes, token revocations, JWT logout. Use
auth:auditto read. OauthManager::handleCallback()now compares the OAuthstatewithhash_equals()(timing-safe) β drop-in replacement.UserLockoutManager::recordFailedAttempt()now incrementsfailed_login_countatomically β drop-in replacement.DeviceSessionRecorderno longer propagates DB errors β they are logged and swallowed so a misconfigured tracking table can't break login.
Upgrading to v5.x¶
What changed¶
OauthManagernow delegates all identity CRUD to a newOAuthTokenRepository.ProfileResolverFactory::create()accepts an optionalarray $providerConfigsecond argument.- New OAuth events fire from
OauthManager::handleCallback(): oauth-loginβ(User $user, string $providerName)oauth-profile-fetchedβ(User $user, string $providerName, array $profileData)extraJSON on OAuth identities now storesscopes_grantedandprofile_fetched_atalongside the existingrefresh_tokenandprofile.
What you must do¶
| If your code⦠| Do this |
|---|---|
Calls model(UserIdentityModel::class) to find an OAuth identity |
Inject OAuthTokenRepository and use findByUserAndProvider() / findByProviderAndSocialId() |
Listens for OAuth login via auth-login or similar custom event |
Switch to the new oauth-login event (see docs/07-logging.md) |
Uses 'oauth_' . $provider string concatenation |
Use IdentityType::oauthProvider($name) |
Relied on the legacy plain-string format in the extra column |
No action required β parseExtra() handles both legacy and JSON formats |
No database migrations are required for the v4 β v5 transition. Existing extra columns continue to work unchanged.
Upgrading to v4.x β Config\Auth split¶
What changed¶
Config\Auth was split into three classes to keep concerns separate. Properties moved according to this table:
| Property | Old class | New class |
|---|---|---|
$minimumPasswordLength, $passwordValidators, $maxSimilarity |
Auth |
AuthSecurity |
$hashAlgorithm, $hashCost, $hashMemoryCost, $hashTimeCost, $hashThreads |
Auth |
AuthSecurity |
$supportOldDangerousPassword |
Auth |
AuthSecurity |
$recordLoginAttempt, $recordActiveDate, $enableLogs |
Auth |
AuthSecurity |
$userMaxAttempts, $userLockoutTime |
Auth |
AuthSecurity |
$enableInvalidAttempts, $maxAttempts, $timeBlocked |
Auth |
AuthSecurity |
$limitMethod, $requestLimit, $timeLimit |
Auth |
AuthSecurity |
$accessTokenEnabled, $unusedAccessTokenLifetime, $strictApiAndAuth |
Auth |
AuthSecurity |
$allowMagicLinkLogins, $magicLinkLifetime |
Auth |
AuthSecurity |
$passwordResetLifetime, $jwtRefreshLifetime |
Auth |
AuthSecurity |
$totpIssuer, $permissionCacheEnabled, $permissionCacheTTL |
Auth |
AuthSecurity |
RECORD_LOGIN_ATTEMPT_* constants |
Auth |
AuthSecurity |
$providers |
Auth |
AuthOAuth |
Constructor signatures changed:
PasswordsandBaseValidatoracceptAuthSecurityinstead ofAuth. Custom password validators extendingBaseValidatormust update their type hints.OauthManageracceptsAuthOAuthinstead ofAuth.
What you must do¶
Step 1. Create the two new config files in app/Config/:
// app/Config/AuthSecurity.php
namespace Config;
use Daycry\Auth\Config\AuthSecurity as AuthSecurityConfig;
class AuthSecurity extends AuthSecurityConfig
{
// Move every customised security/lockout/password property here.
public int $minimumPasswordLength = 10;
public int $userMaxAttempts = 5;
// ...
}
// app/Config/AuthOAuth.php
namespace Config;
use Daycry\Auth\Config\AuthOAuth as AuthOAuthConfig;
class AuthOAuth extends AuthOAuthConfig
{
public array $providers = [
// Move your existing $providers array verbatim from app/Config/Auth.php.
];
}
Step 2. Remove the moved properties from app/Config/Auth.php. Anything not in the table above stays in Auth.
Step 3. Search-and-replace setting('Auth.X') β setting('AuthSecurity.X') (or setting('AuthOAuth.X')) for every property listed above. Common offenders:
| Before | After |
|---|---|
setting('Auth.recordLoginAttempt') |
setting('AuthSecurity.recordLoginAttempt') |
setting('Auth.requestLimit') |
setting('AuthSecurity.requestLimit') |
setting('Auth.userMaxAttempts') |
setting('AuthSecurity.userMaxAttempts') |
setting('Auth.totpIssuer') |
setting('AuthSecurity.totpIssuer') |
setting('Auth.providers') |
setting('AuthOAuth.providers') |
Step 4. Update custom password validators:
use Daycry\Auth\Authentication\Passwords\BaseValidator;
use Daycry\Auth\Config\AuthSecurity;
class MyValidator extends BaseValidator
{
public function __construct(AuthSecurity $config)
{
parent::__construct($config);
}
}
Step 5. Run the test suite β the type system will catch most missed renames.
General upgrade checklist¶
After any major version bump:
composer update daycry/authphp spark migrate --allβ applies any new migrations.composer testβ runs PHPUnit + code-style.- Review your
app/Config/Auth.php,app/Config/AuthSecurity.php,app/Config/AuthOAuth.phpagainst the published versions for any new options worth adopting (e.g.permissionCacheEnabled,tokenLastUsedThrottle). - Check
CHANGELOG.mdfor any non-breaking deprecations to plan ahead of v6.