Skip to content

Credentials ​

How people prove who they are: the password lifecycle (expiry, history, forced change, temporary credentials), and — because the two come up together — what it takes to let people sign in with a username or a phone number instead of an email.

Everything here is off by default. Enabling the module changes nothing until you opt into a rule.


Configuration ​

php
'credentials' => [
    'enabled' => env('KINETIX_CREDENTIALS_ENABLED', false),

    // What a person may sign in with. ['email'] is exactly today's behavior.
    'identity' => [
        'fields'           => ['email'],
        'phone_country'    => '',   // ISO country assumed for a bare local number
        'username_pattern' => '/^[a-zA-Z0-9._-]{3,32}$/',
    ],

    'passwords' => [
        // Days a password stays valid. null = passwords never expire.
        'expires_after_days' => env('KINETIX_PASSWORD_EXPIRES_DAYS'),

        // How many previous passwords may not be reused (0 = off, max 5).
        'history' => env('KINETIX_PASSWORD_HISTORY', 0),

        // How long an UNUSED temporary credential stays valid.
        'temporary_ttl_hours' => env('KINETIX_PASSWORD_TEMPORARY_TTL', 48),

        // Days before expiry that the UI starts warning (0 = never).
        'warn_before_days' => env('KINETIX_PASSWORD_WARN_DAYS', 7),

        // Extra routes the `kinetix.password` middleware lets through.
        'except' => [],

        'view'           => 'Kinetix/PasswordChange',
        'redirect_after' => '/',
    ],
],

Installation ​

bash
php artisan vendor:publish --tag=kinetix-credentials-migrations
php artisan migrate

That adds password_changed_at and must_change_password to your users table, plus the kinetix_password_history table (hashes only — see Security). Upgrading from before 0.208.0, publish again and migrate: a new migration adds the column that keeps each profile's history apart. Until it has run, history works as before, matched by user id alone.

Then append the middleware to your authenticated group:

php
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->appendToGroup('web', \Happones\Kinetix\Credentials\Middleware\EnsurePasswordIsCurrent::class);
})

A policy without the middleware enforces nothing

Kinetix aliases kinetix.password but never applies it — it owns none of your route groups. Expiry configured without the middleware means passwords go stale and nobody is ever asked to change one. php artisan kinetix:doctor reports exactly that.


1. The bookkeeping is automatic ​

The two things the policy needs to know — when the password last changed and which ones have been used — are maintained by a model observer, not by call sites. A password can be set from Fortify, a reset link, a seeder, an admin screen or tinker, and a policy that only holds on the path Kinetix owns is a policy with a hole in it.

So this is all it takes:

php
$user->forceFill(['password' => Hash::make($new)])->save();

password_changed_at is stamped, the history gets an entry, the forced-change flag clears, and the history is pruned to the configured depth. Nothing to remember, nothing to wire.


2. Expiry ​

php
'expires_after_days' => 90,

A user whose password is older than that is redirected to the change screen by the middleware, and KinetixPasswords::isExpired($user) says so anywhere else.

Existing accounts are not locked out

Accounts whose password predates the policy have no password_changed_at. Treating that as "expired" would lock out everyone the moment an admin switches the policy on, so a null stamp counts as current until the next change. If you want the policy to apply retroactively, backfill the column:

php
User::query()->whereNull('password_changed_at')->update(['password_changed_at' => now()->subDays(90)]);

Ask about it directly when you need to:

php
KinetixPasswords::isExpired($user);          // bool
KinetixPasswords::expiresAt($user);          // ?Carbon
KinetixPasswords::daysUntilExpiry($user);    // ?int — negative once past

3. History ​

php
'history' => 3,   // "you can't reuse your last 3 passwords"

The current password counts as one of them, which is the case a naive history table gets wrong: with an empty history, a user could "change" their password to the one they already have.

Add the rule wherever a password is set — including Fortify's own actions, which is the point of it taking the user explicitly:

php
// app/Actions/Fortify/UpdateUserPassword.php
use Happones\Kinetix\Credentials\Rules\NotAPreviousPassword;

Validator::make($input, [
    'current_password' => ['required', 'string', 'current_password:web'],
    'password'         => [
        'required', 'string', Password::default(), 'confirmed',
        new NotAPreviousPassword($user),          // ← add this
    ],
])->validateWithBag('updatePassword');

Do the same in ResetUserPassword and CreateNewUser if you want the rule to apply there too. It is inert while the module is off or history is 0, so it is safe to leave wired.

The depth is capped at 5, on purpose

Each remembered password costs a Hash::check, which is deliberately slow — that is what a password hash is for. At bcrypt's default cost, five comparisons is already ~½ second on a password change. Kinetix caps the depth rather than letting a config typo make changing a password take a minute.


4. Forced change and temporary passwords ​

php
// After a breach, or any time you want the next login to change it:
KinetixPasswords::forceChange($user);

// Issue a credential you hand over — returned ONCE, like a Sanctum token:
$plain = KinetixPasswords::issueTemporary($user);

issueTemporary() hashes on the way in, so the plaintext cannot be read back: show it now or issue a new one. The user is flagged, so the middleware sends them to the change screen on their first request — and that screen does not ask for the current password, because an admin chose it and repeating it back proves nothing.

Delivering it — the TemporaryCredential ​

issueTemporary() returns the bare string. When you want the credential as a first-class object — its validity window and the means to deliver it — use issueTemporaryCredential():

php
$cred = KinetixPasswords::issueTemporaryCredential($user);

$cred->value;        // the plaintext — show/copy it ONCE
$cred->expiresAt;    // CarbonInterface|null — when an unused one stops working
$cred->toArray();    // { value, expiresAt } — e.g. to return as an Inertia prop

$cred->send($user);              // notify the model (mail by default)
$cred->sendMail('ops@acme.dev'); // or an off-model address
$cred->sendVia('vonage', $phone); // or any channel you've registered

The value is redacted in logs, stack traces and dd(), so it can't leak by accident — only ->value exposes it. It can't be serialized either (into a queue payload, the cache or the session): deliver it when you issue it. The notification is queued encrypted, so the plaintext never sits readable in the jobs table, Redis, Horizon or failed_jobs. toArray() does include the value, by design; if you return it as an Inertia prop, render that page with Inertia::clearHistory() (or encrypt history) so the back button can't bring it back.

The default delivery is a translatable mail. sendVia() / sendMail() refuse (throw) a channel the notification doesn't list in via(), instead of sending nothing. Point credentials.passwords.notification at a subclass of TemporaryPasswordNotification to add SMS/other channels (same pattern as Membership's activation notification):

php
// config/kinetix.php → credentials.passwords.notification
class SmsTemporaryPassword extends TemporaryPasswordNotification
{
    public function via(object $notifiable): array { return ['vonage']; }
    public function toVonage(object $notifiable): VonageMessage
    {
        return (new VonageMessage)->content($this->smsContent());
    }
}

Any authenticatable model. All of this is model-agnostic — set credentials.user_model (or just pass the model) and it works on a Client, Customer or any model with the two password columns, not only User. Add the columns to that model's table (the published migration targets users; copy it for another table).

Expiring an unused temporary credential ​

Kinetix does not own your login, so it cannot refuse a stale one for you.

If you use KinetixIdentity::attempt() you get it for free — it already refuses a temporary credential past its TTL. On an email-only app that keeps Fortify's default login, add the check yourself:

php
Fortify::authenticateUsing(function (Request $request) {
    $user = User::where('email', $request->email)->first();

    if (! $user || ! Hash::check($request->password, $user->password)) {
        return null;
    }

    // A temporary credential nobody used within `temporary_ttl_hours` is dead;
    // an admin has to issue a new one.
    if (KinetixPasswords::temporaryHasExpired($user)) {
        return null;
    }

    return $user;
});

5. Signing in with a username or a phone ​

Many businesses have employees who simply do not have an email address — but almost all of them have a phone, and a username can always be assigned.

php
'identity' => [
    'fields'           => ['email', 'username', 'phone'],
    'phone_country'    => 'MX',   // assumed when a number is typed without a country code
    'username_pattern' => '/^[a-zA-Z0-9._-]{3,32}$/',
],
bash
php artisan vendor:publish --tag=kinetix-identity-migrations
php artisan migrate

The migration adds only the columns you accept, and relaxes email to nullable only once something else can identify a person — changing it while it is the sole identifier would let an account be created that nobody can log into. The unique indexes survive the nullability: in MySQL and Postgres NULLs do not collide, so any number of users may have no email while the ones that do still cannot share it.

5.1 Fortify ​

php
// config/fortify.php
'username' => 'login',   // the form field, not the column
php
// app/Providers/FortifyServiceProvider.php
use Happones\Kinetix\Credentials\KinetixIdentity;
use Laravel\Fortify\Fortify;

Fortify::authenticateUsing(fn (Request $request) => KinetixIdentity::attempt(
    $request->input(Fortify::username()),
    $request->input('password'),
));

That is the whole change. attempt() resolves the login, verifies the password and refuses a temporary credential past its TTL — then hands the user back for Fortify to log in. Sessions, throttling and two-factor stay exactly where they were: Kinetix does not own your login.

Your login form posts login instead of email, and creation forms use the generated rules:

php
Validator::make($input, [
    ...KinetixIdentity::rules(),          // or ::rules($user) when updating
    'name' => ['required', 'string', 'max:255'],
])->validate();

Each accepted field is nullable and unique, plus a guard that at least one is present — a user nobody can identify is not a user.

5.2 The two things that make it safe ​

A login is classified before it is queried. An input that looks like an email is only ever matched against email. Without that, someone could register another person's email address as their username and be found by it — the "I logged in as the wrong person" bug. The default username_pattern excludes @ as well, so the collision cannot even be created; kinetix:doctor warns if you widen it.

Every value is normalized identically going in and coming out.+52 55 1234 5678 and 525512345678 are one phone number, and stored inconsistently they defeat the unique index that is supposed to stop them being two accounts. Apply it on writes too:

php
$user->phone = KinetixIdentity::normalize('phone', $request->input('phone'));

Storage is strict; lookup is forgiving

Without a + or 00, digits are read as a local number and get phone_country's dial code. A bare string that already starts with the country code is genuinely ambiguous — 52 55 1234 5678 could be a full Mexican number or a local one that happens to start with 52 — and nothing settles that without a numbering-plan library. So storage refuses to guess, while lookup tries both readings: they are matched against values that were stored canonically, so at most one exists. A person can type their number however they know it.

Two people can still match one login when an all-digit string is both a valid username and a plausible phone. Kinetix resolves that to nobody — an ambiguous identity is worse than a failed login.

Failures are indistinguishable on purpose: attempt() returns null for an unknown identifier, a wrong password and a stale temporary credential alike, and spends the same time on each, so the form is not a directory anyone can enumerate. Keep your own error message generic to match.

5.3 What happens to password reset ​

Short version: nothing changes for anyone who has an email address. Adding username / phone to identity.fields changes how a login is resolved; it does not touch the reset flow at all. An owner or admin with an email keeps self-service reset exactly as before.

That is not a coincidence — login and reset are different endpoints with different fields. config('fortify.username') (what you set to 'login' in §5.1) governs the login form. Reset goes through Laravel's own broker, which:

  1. looks the user up by the email column (EloquentUserProvider::retrieveByCredentials), and
  2. stores the token keyed by that email (DatabaseTokenRepository::create → getEmailForPasswordReset()), and
  3. delivers to the same address.

So the boundary is per-user, not per-app:

The user has…Self-service reset
an email (owner, admins, office staff)works, unchanged
only a username or phonenot available — see below

What a username-only user sees ​

They submit the "forgot password" form, the broker looks for that value in the email column, finds nobody, and answers with the generic "we can't find a user with that email address". Confusing for them, but safe: it is the same answer an unknown address gets, so the form still isn't an enumerable directory.

Point those people at a help desk instead. Which of these you offer is a decision to make before you ship accounts without email:

OptionWhat it means
Admin-issued temporary passwordThe owner issues one and hands it over; the user must replace it on first use. No delivery channel needed at all — this is what §4 is for, and the reason it exists. Nothing extra to build.
Email required for self-serviceStaff without email simply have no self-service path, and the help desk issues them a temporary password.
Reset by SMSGenuinely more work than it sounds — see the warning below.

"Reset by SMS" is not just swapping the notification channel

Laravel's reset token store is keyed by email: DatabaseTokenRepository writes, looks up and deletes rows by getEmailForPasswordReset(). A user whose email is NULL cannot have a usable row there — and two such users would collide on the same null key.

So a real SMS reset needs its own token store (a table keyed by user id or phone) and its own controller, not just a notification on a different channel. If that is where you are heading, an admin-issued temporary password does the same job today with none of that surface — the credential is already single-use and already forces a change.

Email verification ​

Turn off — or make conditional — email verification for these accounts. MustVerifyEmail on a user with a null email loops them on the "verify your email" screen forever, with no way out:

php
public function hasVerifiedEmail(): bool
{
    // Nothing to verify, so nothing to block on.
    return blank($this->email) || ! is_null($this->email_verified_at);
}

5.4 More than one authenticatable — credential profiles ​

Some apps log in two kinds of account: your staff User and, say, a customer-facing Client portal — different models, different tables, maybe different identity fields. Declare each extra one as a profile; the top-level config stays the implicit default:

php
// config/kinetix.php
'credentials' => [
    'user_model' => App\Models\User::class,          // default profile
    'identity'   => ['fields' => ['email']],
    'profiles'   => [
        'client' => [
            'user_model' => App\Models\Client::class,
            'identity'   => ['fields' => ['email', 'phone'], 'phone_country' => 'MX'],
        ],
    ],
];

Point each guard's Fortify (or login action) at the matching profile:

php
// the client guard's authenticateUsing
KinetixIdentity::attempt($login, $password, 'client');

// anywhere you resolve or validate for that profile
KinetixIdentity::resolve($login, 'client');
KinetixIdentity::rules($ignore, 'client');
KinetixIdentity::for('client')->fields();   // the scoped resolver

Each profile resolves against its own model and identity fields — the same email on both tables finds the right record for each. A profile name that isn't declared (or one without a user_model) throws rather than falling back to the default model: a User logged into the client guard would load the Client with the same id. Validate a profile that comes from the request.

The password lifecycle (expiry, history, forced change, temporary credentials) applies to every profile's model: Kinetix observes each one, and each model keeps its own password history even when ids collide (a Client #1 never reads a User #1's passwords). The profiles' models must share the user key type the history table was created with (kinetix.key_types.user). Give each model the two columns with the Blueprint macro:

php
Schema::table('clients', fn (Blueprint $t) => $t->kinetixPasswordColumns());

Give a profile its guard and its users get the change-password screen behind that guard (/_kinetix/password/client). The kinetix.password middleware sends each user to their own profile's screen, so add it after the guard's auth middleware in that group:

php
'client' => [
    'user_model' => App\Models\Client::class,
    'guard'      => 'client',
],

Route::middleware(['web', 'auth:client', 'kinetix.password'])->group(...);

Without a guard the profile still resolves logins, but its users can't reach the screen; kinetix:doctor reports that, and a profile table missing the password columns.

Single-model apps need none of this — omit profiles and nothing changes.

6. Frontend ​

The policy travels on the kinetix_credentials Inertia prop, so a banner can warn before expiry without a round trip:

ts
const { kinetix_credentials: credentials } = usePage().props;

credentials.expiring;          // inside the warning window
credentials.daysUntilExpiry;   // 3
credentials.requiresChange;    // blocked until they change it
credentials.changeUrl;

<KinetixPasswordChange> (published) is the screen the middleware redirects to. Mount it from the page named by credentials.passwords.view and pass the props the controller provides:

vue
<script setup lang="ts">
import KinetixPasswordChange from '@/components/kinetix/KinetixPasswordChange.vue'

defineProps<{
  action: string
  mustChange: boolean
  expired: boolean
  daysUntilExpiry: number | null
  historyDepth: number
}>()
</script>

<template>
  <KinetixPasswordChange v-bind="$props" />
</template>

7. Security ​

  • Only hashes are stored. The history table exists to answer "have you used this before?" without anyone — including an operator with database access — being able to read what the old passwords were.
  • A temporary password is a weaker credential, because it travels out of band and someone else chose it. Give it a short temporary_ttl_hours, enforce that TTL at login (§4), rate-limit your login route, and gate who may issue one behind its own ability rather than lumping it in with "manage members".
  • Issuing one is a privileged act — log it. If you use the Activity module, record the actor, the target and the time; it is the audit trail that makes "someone logged in as an employee" answerable.
  • The middleware answers JSON with 423, not a redirect, so an XHR doesn't silently receive an HTML page it can't interpret.
  • The change screen can never redirect to itself. It, login, logout, password.* and verification.* are always exempt — without them a user with an expired password could neither fix it nor leave. Add your own with passwords.except.
  • Deleting a user forgets their history, which is personal data whose only purpose was to answer questions about that account.

  • Membership & Provisioning — adding people to a team; the provisioning flow that will grow the "no delivery channel" mode this module's temporary passwords are for.
  • Activity Log — where to record credential issuance.
  • GDPR — KinetixPasswords::forget($user) for an erasure request.

Released under the MIT License.