Skip to content

πŸ”„ 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

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

  1. 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
php spark migrate --all

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.

  1. Schedule php spark auth:purge β€” AuthSecurity::$rememberMePurgeChance now defaults to 0 (was 20). 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:

  • JwtController now mints the access-token payload as {uid, tv}, where tv is the user's current token_version. Legacy scalar payloads (a bare user id) are still accepted β€” the tv check is skipped for them.
  • The JWT authenticator's check() rejects a token whose embedded tv does not match the user's current token_version, returning lang('Auth.revokedToken').
  • User::revokeIssuedTokens() bumps token_version atomically, invalidating all outstanding access tokens for that user. It is called automatically by Bannable::ban() and Services\PasswordChangeRecorder::record() (on password reset/change). Call it directly for a "log out everywhere" action:
$user->revokeIssuedTokens(); // every previously-issued JWT access token now fails check()
  • JwtController routes refresh / logout / issue through service('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 the login.suspicious audit event, and fires Events::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'] is true, every authenticated request verifies the current PHP session maps to a non-terminated auth_device_sessions row (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

  1. 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)
php spark migrate --all

!!! 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.

  1. 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:audit to read.
  • OauthManager::handleCallback() now compares the OAuth state with hash_equals() (timing-safe) β€” drop-in replacement.
  • UserLockoutManager::recordFailedAttempt() now increments failed_login_count atomically β€” drop-in replacement.
  • DeviceSessionRecorder no 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:

  1. New writes are normalized. User::setUsername() and User::setEmail() lowercase the value, and UserIdentityModel::createEmailIdentity() stores the email lowercase. Logins remain case-insensitive β€” a user can still type John@Example.com and match.
  2. A data migration lowercases existing rows. 2026-06-05-000002_normalize_login_identifiers lowercases existing users.username values and existing email_password identity 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's down() is a deliberate no-op β€” the original casing is unrecoverable once lowercased.
  3. The migration aborts (and writes nothing) on case-only duplicates. If lowercasing would collapse two distinct rows into one key β€” e.g. users John and john, or two email_password secrets differing only in case β€” the migration detects this first, performs zero writes, and aborts with a report listing the offending ids and values. It never merges or deletes accounts. Resolve the duplicate manually (rename, merge, or delete one of the accounts), then re-run php 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

  • OauthManager now delegates all identity CRUD to a new OAuthTokenRepository.
  • ProfileResolverFactory::create() accepts an optional array $providerConfig second argument.
  • New OAuth events fire from OauthManager::handleCallback():
  • oauth-login β€” (User $user, string $providerName)
  • oauth-profile-fetched β€” (User $user, string $providerName, array $profileData)
  • extra JSON on OAuth identities now stores scopes_granted and profile_fetched_at alongside the existing refresh_token and profile.

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:

  • Passwords and BaseValidator accept AuthSecurity instead of Auth. Custom password validators extending BaseValidator must update their type hints.
  • OauthManager accepts AuthOAuth instead of Auth.

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:

  1. composer update daycry/auth
  2. php spark migrate --all β€” applies any new migrations.
  3. composer test β€” runs PHPUnit + code-style.
  4. Review your app/Config/Auth.php, app/Config/AuthSecurity.php, app/Config/AuthOAuth.php against the published versions for any new options worth adopting (e.g. permissionCacheEnabled, tokenLastUsedThrottle).
  5. Check CHANGELOG.md for any non-breaking deprecations to plan ahead of v6.