Skip to content

Password policy

Security: password policy

An environment sets the authentication baseline; an organization may layer its own policy on top, but only to tighten it. AuthPolicy::tightenedWith() is the only supported way the two combine, so a tenant can demand more of its own people than the operator requires and never less.

That much was straightforward. The part worth writing down is where the rules are applied, because the first implementation got it wrong in an instructive way.

Enforcement sits on the primitive, not on the callers

DatabaseSubjects::create() and DatabaseSubjects::setPassword() call the PasswordPolicyGuard themselves. Every path that sets a credential — signup, invitation acceptance, self-service reset, administrative assignment, bulk import — reaches one of those two methods, so every path inherits the policy without knowing it exists.

The first cut bolted the guard onto individual services instead. It was called from AdminPasswordService and nowhere else, which meant an environment demanding 24 characters got 24 on administrative assignment and whatever floor the calling Livewire form happened to hardcode — 12 — everywhere else, including the reset flow most people actually use. Nothing was broken in the policy engine; the engine simply was not asked.

Putting the check where the credential is written inverts the default. A new caller has to go out of its way to bypass the policy rather than out of its way to honour it.

Inferring the organization

The primitive is handed a subject id and a password. It has no organization context, and resolving the environment baseline alone would reopen the same hole from the other direction: a member of a strict organization could satisfy the looser environment floor through any path that did not happen to carry org context.

So when no organization is named, PasswordPolicyEnforcer resolves the environment baseline tightened by every organization the subject belongs to. A subject bound by two organizations must satisfy both.

A caller that does know the organization should still pass it. AdminPasswordService does, because an administrator seeding a password for someone being invited into a strict organization is not yet a member of it — the primitive cannot infer what has not happened yet. Both checks run and the stricter wins.

Surfacing the refusal

PolicyViolation is an exception, which is right for a primitive and wrong for a form. Forms that set a password should use Identity\Rules\PasswordMeetsPolicy so the refusal lands on the field the person is typing into. It should replace a hardcoded min: rule rather than sit beside one — a fixed number cannot know what the tenant requires, and having both means the smaller one silently defines the UX.

What plaintext import means

Bulk import distinguishes two cases, and the policy binds only one of them:

  • A password hash from another IdP goes through storeCredential() verbatim. There is no plaintext to inspect, so no rule can apply. This is the normal migration route.
  • A plaintext password in an import file goes through create(), and the policy applies. A row whose password is below the floor is reported as a per-row import error, not a crash.

If you have plaintext, you are seeding accounts rather than migrating them, and seeded accounts should meet the same floor as any other.

The three fields that are not about password strength

AuthPolicy also carries maxAgeDays, mfa and lockoutThreshold. Each is enforced by a service of its own, because each fails differently when enforced naively.

maxAgeDaysPasswordExpiry

Rotation needs to know when the password was last set, and the users table is host-owned, so the platform keeps its own password_ages row, stamped by the same primitive that applies the policy. A timestamp maintained by the callers is a timestamp some caller forgets.

A subject with no recorded age does not expire. Their credential predating this being tracked is not evidence of an old password, and locking them out on that assumption is a worse failure than a clock that starts late. The migration seeds every existing subject with the upgrade time so the clock starts for everyone at once rather than never.

Enforcement is a hold on every authenticated request, not a check at sign-in: an already-open session would otherwise outlive the rotation it was supposed to trigger.

mfaMfaMandate

This is the one field that cannot be enforced by refusing something. Turning away a subject who has no factor, on a policy that has just started requiring one, locks out exactly the people who need to enrol. So the mandate is a question — "does this subject still owe one?" — and the host holds them on the enrolment page until the answer is no.

A confirmed TOTP factor and a registered passkey both satisfy it. A passkey is usually the stronger of the two, so treating it as not-a-factor would push people from a better credential to a worse one to satisfy a policy meant to raise the bar.

lockoutThresholdLoginAttempts

Per subject, not per IP. The IP-keyed rate limiting the sign-in forms already do is a different control: it protects the service from a flood, while an attacker spreading guesses across a botnet never trips it and a shared office NAT trips it with nobody being attacked at all.

The lock is checked before the credential, or a locked account still tells an attacker which guess was right.

Two durations are deliberately not policy fields:

  • The counting window (15 minutes). Failures spread thinly over weeks are not an attack in progress, and counting them forever locks out people who occasionally mistype.
  • The lockout duration (15 minutes). A lock that lasts until an administrator intervenes is a denial-of-service tool — anyone who knows an email address can lock its owner out at will. NIST SP 800-63B prefers throttling to hard lockout for this reason: the threshold exists to make guessing impractical, not to punish.