Password Policy
Configurable password rules — minimum and maximum length, complexity (uppercase, digits, special characters), common-password dictionary check, and opt-in HaveIBeenPwned breach detection.
Password Policy
A strong password policy is the baseline of account security. Vortos provides a composable rule engine for validating passwords: configure rules in config/security.php, inject PasswordPolicyService, call validate(), and get back a structured list of violations — not exceptions, because password failure is expected input, not an error.
Reference: NIST SP 800-63B — Memorized Secret Authenticators | OWASP — Authentication Cheat Sheet
Configuration
$config->passwordPolicy()
->minLength(12) // NIST recommends minimum 8; 12 is a good baseline
->maxLength(128) // prevent DoS via bcrypt with extremely long passwords
->requireUppercase(true) // at least one A-Z
->requireLowercase(true) // at least one a-z
->requireDigit(true) // at least one 0-9
->requireSpecial(true) // at least one non-alphanumeric character
->checkCommonPasswords(true) // reject top-10,000 common passwords
// ->hibp(true) // opt-in: HaveIBeenPwned breach check (needs network)
;Using PasswordPolicyService
Inject the service and validate before hashing:
use Vortos\Security\Password\PasswordPolicyService;
final class RegisterUserCommandHandler
{
public function __construct(
private readonly PasswordPolicyService $passwordPolicy,
private readonly PasswordHasher $hasher,
private readonly UserRepository $users,
) {}
public function handle(RegisterUserCommand $command): void
{
$violations = $this->passwordPolicy->validate($command->password);
if ($violations !== []) {
throw new PasswordPolicyException($violations);
}
$hashedPassword = $this->hasher->hash($command->password);
// ... create user
}
}validate() returns list<PasswordPolicyViolation> — an empty array means the password passes all rules.
// Check without collecting violations
if (!$this->passwordPolicy->passes($command->password)) {
throw new \InvalidArgumentException('Password does not meet policy requirements');
}PasswordPolicyViolation
Each violation is a value object with two fields:
use Vortos\Security\Password\PasswordPolicyViolation;
$violations = $this->passwordPolicy->validate($password);
foreach ($violations as $violation) {
echo $violation->rule; // e.g., "min_length"
echo $violation->message; // e.g., "Password must be at least 12 characters"
}| Rule key | Triggered when |
|---|---|
min_length | Password shorter than configured minimum |
max_length | Password longer than configured maximum |
complexity | Missing required character classes |
common_password | Password found in common-password dictionary |
breached | Password found in HaveIBeenPwned database |
Rules in Detail
Length Rules
MinLengthRule and MaxLengthRule use mb_strlen() for correct Unicode character counting.
Why a maximum? Bcrypt's cost function processes up to 72 bytes. A password like 10,000 characters of A still costs the same to hash as 10,000 characters of random noise — but it wastes CPU time parsing and passing the string. A maximum of 128–256 characters is standard practice.
NIST guidance: NIST SP 800-63B recommends a minimum of 8 characters. 12 is a practical baseline for user-facing accounts. Admin accounts should use 16+.
Complexity Rule
ComplexityRule checks for the presence of required character classes using regex:
| Class | Regex | Enabled by |
|---|---|---|
| Uppercase | /[A-Z]/u | ->requireUppercase(true) |
| Lowercase | /[a-z]/u | ->requireLowercase(true) |
| Digit | /[0-9]/ | ->requireDigit(true) |
| Special | /[^A-Za-z0-9]/u | ->requireSpecial(true) |
All missing classes are collected and returned in a single violation message ("Password must contain uppercase letters, digits, and special characters") — not one violation per class.
NIST on Complexity
NIST SP 800-63B (2024 update) recommends against mandatory complexity rules for general consumer passwords, preferring length and breach checks instead. For enterprise and admin passwords, complexity remains widely required. Configure rules that match your user context.
Common Password Check
CommonPasswordRule loads Resources/common-passwords.txt into a hash set at construction time. At validate time, it does a single isset($this->passwords[strtolower($password)]) — O(1) lookup.
The dictionary contains the top 10,000 most commonly used passwords sourced from SecLists. Passwords like password, 123456, qwerty, and iloveyou are caught here regardless of other rules.
HaveIBeenPwned Breach Check (Opt-In)
HaveIBeenPwnedBreachCheck checks whether the password has appeared in any known data breach using the HIBP Passwords API with k-anonymity — your actual password is never sent to the API.
How k-anonymity works:
- Compute SHA-1 hash of the password:
e.g. 5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8 - Send only the first 5 characters (
5baa6) toapi.pwnedpasswords.com/range/5baa6 - The API returns all SHA-1 hash suffixes with breach counts:
1E4C9B93F3F0682250B6CF8331B7EE68FD8:3303003 - Match the local suffix against the returned list
Your actual password hash is never transmitted. The API cannot deduce the full password from the first 5 characters of its SHA-1 hash.
Reference: HIBP k-anonymity blog post | HIBP API docs
$config->passwordPolicy()->hibp(true); // opt-inNetwork Access Required
The HIBP check makes an outbound HTTP request to api.pwnedpasswords.com. It gracefully fails open if the API is unavailable (network down, timeout) — a password is not rejected just because the breach check failed. Still, enable this only in environments with reliable outbound internet access. It adds ~50–150ms to password validation.
Frontend Validation
Return violations to the frontend as structured errors that can be mapped to form fields:
final class RegisterUserController
{
public function __invoke(Request $request): JsonResponse
{
$dto = RegisterUserDto::fromRequest($request);
$violations = $this->passwordPolicy->validate($dto->password);
if ($violations !== []) {
return new JsonResponse([
'error' => 'validation_failed',
'fields' => [
'password' => array_map(
fn($v) => ['rule' => $v->rule, 'message' => $v->message],
$violations,
),
],
], 422);
}
// proceed with registration
}
}Testing Password Policy
Unit test a rule:
use Vortos\Security\Password\Rule\MinLengthRule;
$rule = new MinLengthRule(12);
assertNull($rule->validate('correcthorsebatterystaple')); // passes
assertNotNull($rule->validate('short')); // fails
assertEquals('min_length', $rule->validate('short')->rule);Test the full service:
$violations = $this->passwordPolicy->validate('password123');
assertNotEmpty($violations);
assertContains('common_password', array_column($violations, 'rule'));Troubleshooting
Common passwords not being rejected.
Confirm ->checkCommonPasswords(true) is set. Check that Resources/common-passwords.txt exists in the Security package (it ships with the module). If you're running tests, the service is instantiated with whatever rules are configured — a test container with a minimal config may not have CommonPasswordRule in the chain.
HIBP check always passes (even for password).
HIBP is opt-in and disabled by default. Enable it with ->hibp(true). If it's enabled and still not catching known passwords, the API request may be timing out (fails open by design). Log the curl response in HaveIBeenPwnedBreachCheck::check() temporarily to diagnose.
Password validation errors not reaching the frontend.
validate() returns violations — it does not throw. You must explicitly check the return value and return the errors in your response. Calling passes() is a convenience shortcut but discards violation details.
Request Signing
HMAC-SHA256 signature verification for incoming webhooks — constant-time comparison, replay-window protection, and Stripe-style combined header support.
Field-Level Encryption
Opt-in AES-256-GCM field-level encryption with HKDF-SHA256 per-context sub-key derivation — encrypt specific model fields at rest without encrypting the entire database.