Email & passwords
One-time secrets
Links are 64-character URL-safe tokens; codes are 6–8 digits bound to one address. Both are stored only as keyed HMACs, only the newest secret of a purpose works, and the plaintext exists only in the notification.
| Purpose | Lifetime key | Kind |
|---|---|---|
magic_link | magic_link.ttl | link |
email_otp | email_otp.ttl, .length, .max_attempts | code |
reauthentication | shares email_otp.* | code |
email_verification | verification.ttl, .code_length, .max_attempts | link or code |
password_reset | passwords.reset.ttl | link |
email_change | email_change.ttl | link |
Email verification
- off — nothing is sent.
- optional — verification is sent, not enforced.
- required_for_actions — login allowed; the authentication.verified middleware gates your routes, and the email_verified claim lets other services gate offline.
- required_for_login — email_not_verified after a verified first factor.
'verification' => [
'mode' => env('AUTHENTICATION_VERIFICATION', 'optional'), // off|optional|required_for_actions|required_for_login
'channel' => 'link', // link|code
'ttl' => 86_400,
'code_length' => 6,
'max_attempts' => 5,
'resend_decay' => 60,
'verify_on_email_login' => true,
],
'email_change' => [
'enabled' => true,
'ttl' => 3_600,
'notify_old' => true,
'require_reauthentication' => true, // false switches the gate off; else reauthentication.required_for decides (change_email)
],The channel is a link (fragment URL) or a code verified with token plus email. Resending is a guest endpoint that always answers 202, with the email-request throttles plus a per-account resend_decay cooldown. Verifying issues no new tokens: call refresh to get email_verified into the access token.
Email change
use RoundlyConsulting\Auth\DataTransferObjects\EmailChangeData;
use RoundlyConsulting\Auth\Facades\Authentication;
$email = Authentication::guard('users')->email();
$email->sendVerification($user); // no-op for verified accounts
// Mails a confirmation link to the NEW address and a heads-up to the old one
$email->requestChange($user, new EmailChangeData('[email protected]', $context));
$email->confirmChange($token, $context); // from the link — returns the accountThe request needs a recent re-authentication (while change_email is gated), mails a confirmation link to the new address and — with notify_old — tells the old one. An address already in use gets an account-exists mail and the same 202. Confirming writes and verifies the new address, kills every outstanding secret, applies invalidation.email_changed, mails the old address and fires EmailChanged.
Password policy
'passwords' => [
'reset' => ['enabled' => true, 'ttl' => 3_600, 'login_after' => false],
'change' => ['enabled' => true],
'rehash_on_login' => true,
'policy' => [
'min' => 10,
'max' => 128, // bytes capped at 72 automatically under bcrypt
'letters' => false,
'mixed_case' => false,
'numbers' => false,
'symbols' => false,
'not_identifier' => true,
'uncompromised' => [
'enabled' => env('AUTHENTICATION_PASSWORD_BREACH_CHECK', false),
'threshold' => 0,
'timeout' => 3,
'fail_closed' => false,
],
],
],The policy uses Laravel’s Password rule for length and composition, enforces a 72-byte cap under bcrypt, refuses passwords containing the email’s local part and runs on every password-setting path.
Breached-password check
When enabled, the password is SHA-1 hashed and only the 5-character prefix is sent to the Pwned Passwords range API with padding and a 3-second timeout; a suffix count above threshold rejects it. If the service is down, the check fails open — or, with fail_closed, rejects the password with a validation error (422) — and fires BreachedPasswordCheckFailed either way.
Reset, change and set
use RoundlyConsulting\Auth\DataTransferObjects\ChangePasswordData;
use RoundlyConsulting\Auth\Enums\InvalidationReason;
use RoundlyConsulting\Auth\Facades\Authentication;
$guard = Authentication::guard('users');
$passwords = $guard->passwords();
// Check a candidate against the guard's policy (throws a ValidationException)
$passwords->validate($password, $user);
$passwords->rule($email); // the same policy as a validation rule for your own forms
// Change — the current password is required when the account has one
$tokens = $passwords->change($user, new ChangePasswordData(
currentPassword: $request->input('current_password'),
newPassword: $request->input('password'),
current: $guard->tokenFrom($request),
context: $guard->contextFrom($request),
)); // the re-issued pair for this device — swap to it
// Set — host/admin, invalidation per the given reason
$passwords->set($user, $temporaryPassword, InvalidationReason::Security);- Forgot — throttled; a real, enabled account gets a reset link; always 202.
- Reset — the policy runs before the link is consumed; then the hash, the address marked verified, an unlock (reset_unlocks), invalidation password_reset (all) and a notification. With reset.login_after, the login always goes through an enrolled second factor.
- Change — the current password is required when there is one; without one it is a set and needs a recent re-authentication. Returns the re-issued pair for the caller.
- Set — for hosts and admins: policy, hash, invalidation for the given reason, event and notification.
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.