Vortos
Security

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/security.php
$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 keyTriggered when
min_lengthPassword shorter than configured minimum
max_lengthPassword longer than configured maximum
complexityMissing required character classes
common_passwordPassword found in common-password dictionary
breachedPassword 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:

ClassRegexEnabled 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:

  1. Compute SHA-1 hash of the password: e.g. 5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8
  2. Send only the first 5 characters (5baa6) to api.pwnedpasswords.com/range/5baa6
  3. The API returns all SHA-1 hash suffixes with breach counts: 1E4C9B93F3F0682250B6CF8331B7EE68FD8:3303003
  4. 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/security.php
$config->passwordPolicy()->hibp(true); // opt-in

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

On this page