π 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 before serving the new code β eight 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 |
2026-06-05-000001_add_session_and_login_indexes |
Performance indexes on auth_device_sessions and auth_logins |
2026-06-05-000002_normalize_login_identifiers |
Lowercases existing users.username and email_password identity secrets (data migration β see Login identifiers are now stored lowercase below) |
!!! warning "Migrate first, then deploy β order matters this release"
The login lookup (UserModel::findByCredentials) no longer wraps the
stored column in SQL LOWER(), so it can use the unique index β but it
now relies on the stored value already being lowercase. Run
php spark migrate --all on the existing database before serving the new
code. If the new code is deployed first, legacy mixed-case rows (e.g. a
user stored as John@Example.com) will not match a login attempt until
the data migration has run. See the dedicated note below.
- 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 |
| WebAuthn / Passkeys (passwordless login + 2FA) | AuthSecurity::$webauthnEnabled = true |
WebAuthn / Passkeys |
| 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.
Login identifiers are now stored lowercase¶
To let the login lookup use the unique index, this release makes the
username / login-email match index-friendly: the query no longer wraps the
stored column in SQL LOWER(). Instead, the stored value is normalized to
lowercase at write time and only the input is lowercased at read time. Three
things change for you:
- New writes are normalized.
User::setUsername()andUser::setEmail()lowercase the value, andUserIdentityModel::createEmailIdentity()stores the email lowercase. Logins remain case-insensitive β a user can still typeJohn@Example.comand match. - A data migration lowercases existing rows.
2026-06-05-000002_normalize_login_identifierslowercases existingusers.usernamevalues and existingemail_passwordidentity secrets (the stored email). This is why you migrate before you deploy (see Required steps): until it runs, legacy mixed-case rows won't match under the new index-friendly query. The migration'sdown()is a deliberate no-op β the original casing is unrecoverable once lowercased. - The migration aborts (and writes nothing) on case-only duplicates. If
lowercasing would collapse two distinct rows into one key β e.g. users
Johnandjohn, or twoemail_passwordsecrets differing only in case β the migration detects this first, performs zero writes, and aborts with a report listing the offendingids and values. It never merges or deletes accounts. Resolve the duplicate manually (rename, merge, or delete one of the accounts), then re-runphp spark migrate.
Scope is strict β only username and login email are touched
Only users.username and the email_password identity secret are
lowercased. OAuth social-ids, access-token / JWT-refresh hashes,
magic-link / 2FA / reset codes, TOTP secrets, and WebAuthn credentials are
left exactly as-is (case preserved). If you log in with a custom
validFields entry other than username, that field keeps its previous
case-insensitive matching (the query still uses LOWER() for it), so no
data migration is needed or performed for it.
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.