mainul / password-policy
A Laravel package for password policy enforcement: validation rules, expiry, and reuse prevention
Requires
- php: ^8.2
- illuminate/auth: ^10.0|^11.0|^12.0|^13.0
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/hashing: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/mail: ^10.0|^11.0|^12.0|^13.0
- illuminate/routing: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- illuminate/validation: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.0|^11.0|^12.0
Suggests
- laravel/passport: Alternative API authentication: set api.guard to your passport guard.
- laravel/sanctum: Default authentication guard for the JSON API (api.guard = sanctum).
- xenon/laravelbdsms: Required to deliver temporary passwords by SMS when a user has no company email (policy 3.1.3).
Provides
None
Conflicts
None
Replaces
None
README
A Laravel package for enforcing password and account security controls in Laravel applications.
Features
- Password rules: configurable length, uppercase, lowercase, number and special-character requirements, personal-information checks, banned words and pattern detection. Administrators can use stricter rules.
- Password history: prevents users from reusing recent passwords and verifies the current password for voluntary changes.
- Temporary passwords: generates random passwords, delivers them through an approved email or SMS channel and requires a change at first login.
- Account lockout: supports tiered lockout periods, administrator unlocks and rapid-failure detection by IP.
- Password expiry: prompts users when passwords expire and can disable overdue accounts automatically.
- Session termination: ends an account's web sessions and API tokens after password changes, compromise or offboarding.
- Audit logging and monitoring: records security events, redacts secrets and detects unusual login activity.
- Multi-factor authentication: includes optional authenticator-app login and supports custom MFA drivers.
- Password reset protection: supports one-time identity-verification codes and role-based reset restrictions.
- Service accounts: tracks owners and purposes, schedules credential rotation and integrates with a vault callback.
- Offboarding and security commands: disables departing users, flags shared credentials and provides diagnostic, audit and maintenance commands.
- JSON API: every feature above is also available over HTTP for mobile apps, SPAs and other services. Uses Sanctum by default; Passport or any guard can be chosen in config or the database (see JSON API).
Requires PHP 8.2+ and Laravel 10 to 13.
This package provides the controls. Several of them only take effect once you wire them into your application (see Setup). The compliance table at the end says which.
Install
composer require mainul/password-policy
php artisan vendor:publish --tag=password-policy-config
php artisan migrate # the package loads its own migrations
php artisan password-policy:check
Optional: composer require bacon/bacon-qr-code to show a QR code on the authenticator-app set-up page.
Optional: composer require xenon/laravelbdsms to deliver temporary passwords and reset codes by SMS
when a user has no company email.
Setup
-
User model: add the trait and hide the password.
use Mainul\PasswordPolicy\Traits\HasPasswordPolicy; class User extends Authenticatable { use HasPasswordPolicy; protected $hidden = ['password', 'remember_token']; }
The trait records password history, stamps
password_changed_at, ends the user's other sessions after a password change, and writes the audit entry. -
Routes: add the middleware you need (all are aliased by the package).
Alias Put it on Policy password.expiryauthenticated routes 3.5.1 password.force_changeauthenticated routes 3.1.5 password.account_activeauthenticated routes 3.4.2, 3.5.1, 3.8.3 password.session_versionauthenticated routes 3.5.2, 3.8.4 password.mfaauthenticated routes 3.4.3, 3.4.4, 3.9.2 password.securelogin, password and reset routes 3.3 password.block_self_resetforgot-password and reset-password routes 3.6.6 The routes named in
middleware.excluded_routes(defaultpassword.change,password.update,logout) must stay reachable so users can actually change their password. -
Login action: call the guard before checking credentials, so locked or disabled accounts get no chance to guess:
use Mainul\PasswordPolicy\Services\LoginGuard; use Mainul\PasswordPolicy\Exceptions\AccountLockedException; try { app(LoginGuard::class)->ensureCanAttempt($request->input('email')); } catch (AccountLockedException $e) { return back()->withErrors(['email' => $e->getMessage()]); // same message for every reason }
Laravel's
FailedandLoginevents feed the lockout counters automatically. -
Change-password form: validate with the package rules.
use Mainul\PasswordPolicy\Rules\{PasswordPolicyRule, PasswordNotReusedRule, CurrentPasswordRule}; $request->validate([ // A rule object is skipped when the field is missing, so require it too. 'current_password' => ['required', new CurrentPasswordRule($user)], // 3.5.4 'password' => ['required', 'confirmed', PasswordPolicyRule::for($user), // admins get stricter rules new PasswordNotReusedRule($user)], // 3.5.3 ]);
-
Forgot-password form (optional, 3.6.6):
'email' => [new CanSelfResetPassword()].Reset identity check (3.6.2, optional, useful for phone-only users): send and check a one-time code in your reset flow before accepting the new password.
$verifier = app(\Mainul\PasswordPolicy\Contracts\ResetIdentityVerifier::class); $verifier->issue($user); // code goes to company email, else phone if (! $verifier->verify($user, $request->code)) { // single use, 10 min, locks after 5 wrong tries abort(422, 'Invalid or expired code.'); }
-
Logs: to keep secrets out of application logs (3.7.2), add the tap to your channel:
'single' => ['driver' => 'single', 'path' => storage_path('logs/laravel.log'), 'tap' => [\Mainul\PasswordPolicy\Logging\RedactSensitiveData::class]],
-
Audit log access (3.7.3): reading is denied to everyone until you define the gate:
Gate::define('view-password-audit-logs', fn ($user) => $user->isSecurityOfficer());
-
Schedule:
password-policy:disable-expiredis scheduled daily automatically. Schedulepassword-policy:rotation-due --notify(and--rotateif you want automatic rotation) yourself.
Password Control Policy (PCP/01) defaults
The package ships with the following password-control defaults:
| Setting | Default | Policy |
|---|---|---|
rules.min_length |
11 | At least 11 characters |
rules.require_* |
on | Upper, lower, number and special character |
expiry.days |
30 | Change every 30 days |
history.max_reuse_count |
5 | No reuse of the last five passwords |
Three more checks in rules cover the "don't" list. All are on by default:
forbid_personal_info: rejects a password containing the user's name parts, the part of their email before the@, their phone number or their birthdate. It needs the user, so validate withPasswordPolicyRule::for($user)(without a user this check is skipped). The birthdate is read fromcolumns.birthdate(defaultdate_of_birth).banned_words: rejects passwords containing any listed word, ignoring case. Edit the list in the published config to include common weak passwords and any organization-specific terms.forbid_patterns: rejects four or more repeated characters (aaaa) and four or more sequential ones (1234,abcd,dcba).
Admins keep stricter values (14 characters, 60-day expiry): the stricter of the global and admin setting applies, so with the 30-day default admins also change every 30 days.
Not enforceable in code: sharing or writing down passwords, reusing a password across systems, browser "Remember Password", and keeping an admin password different from the same person's user-account password.
If you upgrade an existing app, the stricter defaults and checks apply to the next password change. Set the old values in your published config to keep them.
Authenticator app login (TOTP)
An optional second step: after the password, the user types the 6-digit code from Google Authenticator, Microsoft Authenticator, Authy or any TOTP app. It is off by default. Once enabled it is required, and users without an app are sent to a set-up page.
php artisan migrate # adds mfa_credentials and password_policy_settings
php artisan password-policy:totp --enable --scope=all --grace-days=7
Then either use the bundled pages or build your own on TotpEnrollmentService:
- Set
totp.routestotruein the published config. This addsmfa/enroll,mfa/challengeandmfa/recovery-codes(route namesmfa.enroll,mfa.enroll.confirm,mfa.challenge,mfa.verify,mfa.recovery). Publish the views with--tag=password-policy-viewsto restyle them. - Put the
password.mfamiddleware on your authenticated routes. After a password login it redirects to the set-up page (no app yet) or the code page (app set up, code not yet entered). - Optional QR code:
composer require bacon/bacon-qr-code. Without it the page shows the key to type in.
Config or database? config/password-policy.php (totp block) holds the defaults. The
password_policy_settings table holds runtime overrides for totp.enabled, totp.scope, totp.grace_days
and totp.issuer; the database wins. Change them with password-policy:totp or the API (PUT admin/settings) so the change is audited. To pin
the policy in code, set totp.enabled and totp.lock_to_config to true: the database can then not switch it
off. If the settings table is unreachable the config defaults are used.
- Who:
totp.scopeisall,adminsorremote(outsidemfa.internal_networks). Admins always need MFA. - Grace:
totp.grace_dayslets users who have not enrolled sign in for that long afterpassword-policy:totp --enable. Users who have enrolled are always challenged. Enabling through config alone records no start time, so there is no grace period. - Storage: the secret is stored encrypted with
APP_KEY(it cannot be hashed, the server must compute codes). ChangingAPP_KEYmakes every stored secret unreadable, so users would need a reset. Recovery codes are stored as keyed hashes and shown once. - Protection: a code can be used once (replay blocked), wrong codes count toward the normal lockout, and the session id is renewed after success. An enrolled app can not be replaced from a password-only session.
- Lost phone: the user enters a recovery code, or an administrator runs
password-policy:totp --reset={id|email}; the user then sets up again at next login. - A
mfa.driveryou configure yourself takes priority over the built-in driver.
JSON API
Everything above is also available over HTTP, for mobile apps, SPAs and other services. It is off by
default: the routes exist but answer 404 until you switch it on, either in the published config
('api' => ['enabled' => true]) or at runtime with no deploy:
app(\Mainul\PasswordPolicy\Services\SettingsRepository::class)->set('api.enabled', true);
Authentication. The guard is sanctum by default (composer require laravel/sanctum). For Passport set
api.guard to the guard whose driver is passport (usually api); a list such as ['sanctum', 'api'] tries each
in turn. Set it in config/password-policy.php or in the database (api.guard, checked against
config/auth.php); the database wins. The guard is read on every request, so a change applies at once.
Authorization. The /admin endpoints are checked against Gate abilities that deny everyone until you define
them (api.abilities; the audit log uses audit.gate_ability):
Gate::define('password-policy.manage-users', fn ($user) => $user->isSecurityOfficer()); Gate::define('password-policy.manage-service-accounts', fn ($user) => $user->isSecurityOfficer()); Gate::define('password-policy.manage-settings', fn ($user) => $user->isSecurityOfficer()); Gate::define('password-policy.view-compliance', fn ($user) => $user->isSecurityOfficer());
By default the /admin endpoints also sit behind password.expiry, password.force_change and password.mfa
(api.admin_requires_mfa, read when routes load). Admins therefore need an enrolled authenticator app first.
All paths are relative to api.prefix (default /api/password-policy). Errors are JSON:
{"message": "...", "error": "code"}, or Laravel's {"errors": {...}} for validation.
| Method and path | Who | Purpose |
|---|---|---|
POST login/check |
anyone, throttled | Is this account allowed to try a password? 423 if locked or disabled. Call before checking credentials. |
POST reset/code |
anyone, throttled | Send a one-time code to the user's company email or phone. Always 202. |
POST reset/password |
anyone, throttled | email, code, password, password_confirmation. Checks the code, applies the policy, sets the password. |
GET policy |
signed in | The rules that apply to the caller (admins get stricter ones). |
GET password/status |
signed in | Expiry date, days left, forced-change flag, MFA state. |
POST password/validate |
signed in | Check a candidate password; changes nothing. |
PUT password |
signed in | current_password, password, password_confirmation. Ends the user's other sessions and tokens. |
GET mfa/status |
signed in | Whether MFA is on, required, enrolled, and verified for this token. |
POST mfa/enroll |
signed in | Start set-up: returns the key, the otpauth:// URI and (with bacon/bacon-qr-code) an SVG. |
POST mfa/enroll/confirm |
signed in | code from the app. Returns the recovery codes once. |
POST mfa/challenge |
signed in | Start a challenge for drivers that need one (send an SMS code). Works with any MFA driver. |
POST mfa/verify |
signed in | code (or a recovery code). Marks this token as having passed MFA. Works with any MFA driver. |
POST mfa/recovery-codes |
signed in | password. New recovery codes; needs a verified token. Built-in app only. |
GET admin/users/{id} |
users | Security state of an account. |
GET admin/users/{id}/known-logins |
users | Countries and dates of the networks the user has signed in from (the unusual-login baseline). |
POST admin/users/{id}/lock |
users | reason optional. Permanent lock; unlocking needs verified_by. |
POST admin/users/{id}/unlock |
users | verified_by is required for permanent and identity-verification locks. |
POST admin/users/{id}/disable, .../enable |
users | reason optional. Enabling forces a password change. |
POST admin/users/{id}/compromise |
users | Make the password unusable and end every session (3.7.5). |
POST admin/users/{id}/temporary-password |
users | Send a new temporary password to the approved channel. It is never returned. |
POST admin/users/{id}/terminate-sessions |
users | End all sessions and API tokens. |
POST admin/users/{id}/offboard |
users | Disable a leaver and list the shared passwords to rotate. |
DELETE admin/users/{id}/mfa |
users | Wipe a user's authenticator app (lost device). |
POST admin/accounts/disable-expired |
users | dry_run optional. Disable accounts past expiry plus grace. |
GET admin/service-accounts[?due=1] |
service accounts | List. |
POST admin/service-accounts |
service accounts | user_id, owner, purpose, rotation_days, known_by[]. |
PUT, DELETE admin/service-accounts/{id} |
service accounts | Change owner, purpose, rotation period, known_by; or stop treating the user as a service account. |
POST admin/service-accounts/{id}/rotate, .../rotate-due |
service accounts | Rotate through your vault. |
POST admin/service-accounts/notify-due |
service accounts | Email owners and notify_recipients about overdue rotations. Returns how many were told. |
GET, PUT admin/settings, DELETE admin/settings/{key} |
settings | Database overrides: totp.*, api.enabled, api.guard. |
GET admin/audit |
audit gate | Filters: user_id, event, outcome, from, to, per_page. |
GET admin/compliance |
compliance | The password-policy:check self-test. |
POST admin/compliance/default-passwords, GET .../{scan_id} |
users | Default-password scan. With user_id it answers at once, otherwise it runs as a queued job. |
- MFA for tokens. API clients have no session, so a passed MFA code is remembered per bearer token in the cache
for
mfa.token_ttl_minutes(default 12 hours). Use a shared cache store on more than one server. Cookie-based SPA requests keep using the session. - Reset codes.
reset/passwordspends the code before it runs the checks that need the user (reuse, personal information), so those checks cannot be used to probe accounts. If one fails, the user asks for a new code. - Your own MFA driver.
mfa/challenge,mfa/verifyandmfa/statuswork with anymfa.driver. Enrolment and recovery codes belong to the built-in authenticator app; with a host driver those two endpoints answer404(mfa_external_driver) because set-up is your driver's job. - Not part of the API: the login action itself (the package has none; use
login/checkbefore yours), and the Laravel reset-link flow with itspassword.block_self_resetmiddleware (the API's own reset applies the same restricted-role rule).
Commands
| Command | Purpose |
|---|---|
password-policy:check |
Self-test: hashing driver, hidden password, HTTPS in production, reset link expiry, MFA driver, admin separation, SMS dependency. Exits non-zero on a failure. |
password-policy:check-defaults [--flag] [--user=] |
Find accounts using a known default password (3.9.5). Slow on big user tables; run before go-live. |
password-policy:disable-expired [--dry-run] |
Disable accounts unchanged past expiry plus grace. |
password-policy:rotation-due [--notify] [--rotate] |
List, notify about, or rotate overdue service accounts. |
password-policy:offboard {user} [--actor=] |
Disable a leaver and flag the shared passwords they knew. |
password-policy:totp [--enable] [--disable] [--status] [--scope=] [--grace-days=] [--reset=] [--actor=] |
Turn the authenticator-app login on or off, show its state, or wipe a user's app after a lost device. |
password-policy:audit [--user= --event= --outcome= --limit=] |
Show recent audit entries (server access only). |
Configuration
Everything lives in config/password-policy.php and is documented inline. The main groups:
rules, expiry, history, lockout, temporary_password, reset, anomaly, tls, audit,
mfa, totp, api, admin, service_accounts, defaults, sessions, columns, current_password, middleware
(routes excluded from the expiry and forced-change checks) and user_model.
Production only: HTTPS enforcement (tls.enforce = null) is active only when APP_ENV=production.
Set it to true or false to force it.
Things to know before relying on it
- Shared cache: the IP-burst counter and the repeated-failure counter use the cache. On more than one
server use a shared store (Redis or database), not
arrayorfile. - Lockout wording: policy clause 3.4.2 does not define "second occurrence" or "extended temporary lockout".
The defaults are an interpretation and are configurable. With the default IP burst (5 in 60 s) a person who
mistypes 5 times in a minute is locked until an administrator unlocks them. Raise
lockout.ip_burst.attemptsif that is too strict. - MFA factor: until you enable the built-in authenticator app (
totp.enabled) or provide anMfaDriver, users who need MFA are blocked. - Self-reset block: the response for a restricted role (BA, SO) reveals that the account exists and has that role. Unknown emails get the normal answer.
- Required columns: the package migrations add
password_changed_at,must_change_password,disabled_at,disabled_reason,locked_until,failed_login_count,lockout_level,is_permanently_locked,requires_identity_verification,last_failed_login_atandsession_versiontousers. The table nameusersis fixed in the migrations. Users that already existed getpassword_changed_atset to the migration time so they are not expired immediately. - Delivery is synchronous: temporary passwords, reset notices and alerts are sent inline, not queued.
- Admin defaults are an assumption: minimum length 14, expiry 60 days, history 5. The policy only says "stronger controls". The stricter of the global and admin value applies.
Policy compliance
Legend: ✅ implemented by the package · 🔌 implemented, but needs your wiring or a host-supplied part · 🅻 relies on Laravel itself, the package verifies or adds to it · ➖ outside what a package can do.
| Clause | Requirement | Status | Where |
|---|---|---|---|
| 3.1.1 | Identity verified via form FRM-001 | ➖ | Business process |
| 3.1.2 | Random, unpredictable temporary password | ✅ | TemporaryPasswordService |
| 3.1.3 | Secure channel: company email, else registered phone | 🔌 | Mail; SMS needs xenon/laravelbdsms |
| 3.1.4 | Never an unprotected channel | ✅ | No fallback; NoSecureChannelException |
| 3.1.5 | Forced change at first login | 🔌 | password.force_change middleware |
| 3.1.6 | Temporary password cannot stay permanent | ✅ | Trait + history |
| 3.2.1–3.2.3 | Hashed, salted, never plaintext | 🅻 | Laravel hashing; password-policy:check verifies the driver |
| 3.2.4 | Password masked everywhere | 🔌 | $hidden check, audit redaction, log tap |
| 3.2.5–3.2.6 | No hard-coded secrets, use a vault | ➖ | Developer practice |
| 3.3.1 | TLS only | 🔌 | password.secure, production only |
| 3.3.2 | No passwords in URLs/query strings | 🔌 | password.secure (query string; headers are not inspected) |
| 3.4.1 | Limit consecutive failures | 🔌 | LockoutService + LoginGuard |
| 3.4.2 | Tiered, extended and IP-burst lockout | 🔌 | LockoutService + LoginGuard |
| 3.4.3 | MFA enforced for admin, remote, external | 🔌 | password.mfa + the built-in authenticator app (totp.enabled) or your MfaDriver |
| 3.4.4 | MFA available for standard users | 🔌 | mfa.require_all or totp.scope = all + a driver |
| 3.5.1 | Max age, prompt, disable 1 day later | ✅ | Expiry middleware, disable-expired, password.account_active |
| 3.5.2 | Old password invalid after change | ✅ | Trait ends other sessions and tokens |
| 3.5.3 | Password history | ✅ | PasswordNotReusedRule |
| 3.5.4 | Current password for voluntary change | 🔌 | CurrentPasswordRule |
| 3.6.1 | Self-service reset | 🅻 | Laravel password broker |
| 3.6.2 | Independent identity check on reset | 🔌 | ResetIdentityVerifier (one-time code) |
| 3.6.3 | Short-lived, single-use links | 🅻 | Laravel broker; password-policy:check verifies expiry |
| 3.6.4 | Never email a password on reset | 🅻 | Laravel broker; notices never contain a password |
| 3.6.5 | Notify the user after a reset | ✅ | NotifyPasswordReset |
| 3.6.6 | BA/SO cannot self-reset | 🔌 | password.block_self_reset, CanSelfResetPassword |
| 3.7.1 | Log logins, changes, resets | ✅ | AuditLogger (reset requests only on Laravel versions that fire the event) |
| 3.7.2 | No secrets in logs | ✅ | Audit redaction; 🔌 log tap for application logs |
| 3.7.3 | Restricted log access | 🔌 | Gate denies until you define it |
| 3.7.4 | Unusual-login monitoring and alerts | ✅ | AnomalyDetector |
| 3.7.5 | Force reset, end all sessions on compromise | ✅ | AccountService::markCompromised() |
| 3.8.1–3.8.2 | Role-change forms and approvals | ➖ | Business process |
| 3.8.3 | Disable a leaver the same day | ✅ | AccountService::disable(), password-policy:offboard |
| 3.8.4 | Terminate sessions and tokens | 🔌 | SessionTerminator + password.session_version |
| 3.8.5 | Rotate passwords a leaver knew | ✅ | Offboarding marks service accounts due; rotation needs your vault |
| 3.9.1 | Admin accounts separate from daily accounts | 🔌 | Detected by password-policy:check, not enforced |
| 3.9.2 | Stronger admin passwords, MFA always | 🔌 | PasswordPolicyRule::for($user); MFA needs a driver |
| 3.9.3 | Service accounts: owner and purpose | ✅ | ServiceAccount |
| 3.9.4 | Vaulted, rotating service passwords | 🔌 | Rotation here; the vault is yours |
| 3.9.5 | Change defaults before go-live | ✅ | password-policy:check-defaults |
Testing
composer install vendor/bin/phpunit
License
MIT