---
title: "Email & passwords — Auth for Laravel | Roundly"
description: "One-time links and codes, email verification modes, verified email change, the password policy, breached-password check and reset flows."
url: https://roundly-consulting.com/open-source/docs/auth-for-laravel/email-passwords
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Auth for Laravel](https://roundly-consulting.com/open-source/docs/auth-for-laravel.md)

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

```php
'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

```php
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('ada@new.example.com', $context));
$email->confirmChange($token, $context);         // from the link — returns the account
```

The 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 instead (in the holder’s locale, under the 10-minute per-address cooldown registration and invitation acceptance share) and the same 202; with notify\_old the old address is told either way, so the requester’s own mailbox never shows whether the address is taken. EmailChangeRequested fires only when a link was issued — a server-side signal: never surface it to the requester, or it reveals a taken address. Confirming writes and verifies the new address, kills every outstanding secret, applies invalidation.email\_changed, mails the old address and fires EmailChanged.

## Password policy

```php
'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

```php
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; actor: tells the owner an administrator did it
$passwords->set($user, $temporaryPassword, InvalidationReason::Security, actor: $admin);
```

- 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. Pass the admin as actor: and the owner’s mail says “changed by an administrator” (PasswordChanged carries the admin as actor); without one it keeps the “your password was changed” copy.

## 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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

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

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/auth-for-laravel.md)
