Docs/Account/Guides/Password Policies

Password Policies

Configure per-tenant password requirements to enforce strength, rotation, and history rules. Policies are enforced at registration, password change, and password reset.

How Policies Are Enforced

Password policies are always checked server-side. Every call to register, change password, or reset password validates the new password against the tenant's configured policy. There is no way to bypass this — even API calls from admin tokens are subject to policy checks.

For better UX, the public endpoint GET /public/password-policy/:slugexposes the tenant's policy rules without requiring authentication. Use this to validate passwords client-side before submitting — so users see real-time feedback as they type.

On failure:If a password doesn't meet the policy, the API returns 422 Unprocessable Entity with specific failure reasons (e.g., too_short, missing_symbol). This lets you display targeted error messages to the user.

Available Policy Fields

The following fields can be configured in the password_policy section of tenant settings. All fields are optional — omit a field to keep its default value.

FieldTypeRangeDefaultDescription
min_lengthinteger8–1288Minimum number of characters
require_uppercasebooleanfalseAt least one uppercase letter (A–Z)
require_lowercasebooleanfalseAt least one lowercase letter (a–z)
require_numbersbooleanfalseAt least one digit (0–9)
require_symbolsbooleanfalseAt least one symbol (!@#$%^&*...)
max_age_daysinteger0–3650Force password change after N days (0 = disabled)
history_countinteger0–240Remember last N password hashes to prevent reuse (0 = disabled)

Configuring a Password Policy

Update the tenant's password policy via the settings endpoint. The password_policy object is merged (not replaced) — you only need to send the fields you want to change.

Below is a strict enterprise example that requires 12+ characters, all character types, 90-day rotation, and remembers the last 12 passwords:

PATCH/api/v1/settings🔒 Auth

Configure password policy for the tenant.

curl -X PATCH https://id.vyntech.com.au/api/v1/settings \ -H "Authorization: Bearer eyJhbGciOiJFZERTQSIs..." \ -H "Content-Type: application/json" \ -d '{ "password_policy": { "min_length": 12, "require_uppercase": true, "require_lowercase": true, "require_numbers": true, "require_symbols": true, "max_age_days": 90, "history_count": 12 } }'
Request Body
{
  "password_policy": {
    "min_length": 12,
    "require_uppercase": true,
    "require_lowercase": true,
    "require_numbers": true,
    "require_symbols": true,
    "max_age_days": 90,
    "history_count": 12
  }
}

Client-Side Validation

The public endpoint returns the tenant's password policy without requiring authentication. Use this to show real-time validation feedback as users type their password — no server round-trip needed until final submission.

GET/public/password-policy/:slugPublic

Fetch the password policy for a tenant (no auth required).

curl https://id.vyntech.com.au/api/v1/public/password-policy/acme-corp

Real-Time Validation Example

Here's a complete JavaScript implementation showing how to integrate client-side validation with a password input field:

Real-Time Validation — JavaScript
// 1. Fetch policy once when the form mounts
let policy = null;

async function loadPolicy(tenantSlug) {
  const res = await fetch(
    `https://id.vyntech.com.au/api/v1/public/password-policy/${tenantSlug}`
  );
  policy = await res.json();
  renderRequirements(policy);
}

// 2. Render requirements list (so users know what's expected)
function renderRequirements(policy) {
  const list = document.getElementById("password-requirements");
  list.innerHTML = "";

  const rules = [
    `At least ${policy.min_length} characters`,
    policy.require_uppercase && "One uppercase letter (A–Z)",
    policy.require_lowercase && "One lowercase letter (a–z)",
    policy.require_numbers && "One number (0–9)",
    policy.require_symbols && "One special character (!@#$...)",
  ].filter(Boolean);

  rules.forEach((rule) => {
    const li = document.createElement("li");
    li.textContent = rule;
    li.dataset.rule = rule;
    list.appendChild(li);
  });
}

// 3. Validate on every keystroke
function onPasswordInput(e) {
  const password = e.target.value;
  const results = validatePassword(password, policy);

  // Update UI — mark each requirement as met/unmet
  results.forEach(({ rule, met }) => {
    const li = document.querySelector(`[data-rule="${rule}"]`);
    if (li) {
      li.classList.toggle("text-green-500", met);
      li.classList.toggle("text-red-400", !met);
    }
  });

  // Enable/disable submit button
  const allMet = results.every((r) => r.met);
  document.getElementById("submit-btn").disabled = !allMet;
}

function validatePassword(password, policy) {
  return [
    { rule: `At least ${policy.min_length} characters`, met: password.length >= policy.min_length },
    policy.require_uppercase && { rule: "One uppercase letter (A–Z)", met: /[A-Z]/.test(password) },
    policy.require_lowercase && { rule: "One lowercase letter (a–z)", met: /[a-z]/.test(password) },
    policy.require_numbers && { rule: "One number (0–9)", met: /[0-9]/.test(password) },
    policy.require_symbols && { rule: "One special character (!@#$...)", met: /[!@#$%^&*()_+\-=\[\]{};':\"|,.<>?/~`]/.test(password) },
  ].filter(Boolean);
}

Remember: Client-side validation is for UX only. The server always re-validates. Never trust client-side checks as the sole enforcement mechanism.

Password Rotation (max_age_days)

When max_age_days is set to a value greater than 0, users are forced to change their password after N days. The system tracks when each user last changed their password and evaluates expiry on every login attempt.

How It Works

  1. 1User logs in with correct email + password.
  2. 2Server checks password_changed_at + max_age_days against current time.
  3. 3If expired, the response includes password_expired: true and a temporary password_change_token instead of access tokens.
  4. 4Your app redirects to a "change password" screen. Use the temporary token to call POST /api/v1/auth/change-password.
  5. 5After successful password change, the server issues normal access + refresh tokens.

Expired Password Response

Login Response — Password Expired
{
  "password_expired": true,
  "password_change_token": "pct_01H9ABCD...",
  "expires_in": 300,
  "message": "Password expired. Use the token to set a new password."
}

UX Implications

  • The password_change_token is short-lived (5 minutes). If the user doesn't change their password in time, they must log in again.
  • Users are not pre-warned before expiry. Consider implementing a client-side countdown (check password_changed_at from the user profile) to show a "password expires in X days" banner.
  • If MFA is enabled, MFA verification happens first, then the password expiry check occurs. The flow is: password → MFA → expired check → change password → tokens.

Password History (history_count)

When history_countis set to a value greater than 0, the system remembers the last N password hashes for each user. When a user attempts to set a new password, it's checked against all stored hashes.

  • Hashes are stored using the same Argon2id algorithm as the current password — comparison is timing-safe.
  • If the new password matches any of the last N hashes, the API returns 422 with the reason password_recently_used.
  • History is per-user and persists across password resets — you can't bypass history by using "forgot password".
  • If you increase history_count from 5 to 12, existing users won't retroactively gain 12 entries — only future changes are tracked. Their existing history (up to 5) is preserved.

Tip: Combine history_count with max_age_days to prevent users from cycling through passwords quickly to get back to their favorite one. With history_count: 12 and max_age_days: 90, a user would need to wait 3 years before reusing a password.

Error Responses

When a password fails the policy, the API returns a 422 response with a structured error body. The details array contains machine-readable reason codes that you can map to user-friendly messages.

422 Response — Policy Violation
{
  "error": "password_policy_violation",
  "details": ["too_short", "missing_symbol"]
}

Possible Detail Codes

CodeMeaning
too_shortPassword is shorter than min_length
missing_uppercaseNo uppercase letter found
missing_lowercaseNo lowercase letter found
missing_numberNo digit found
missing_symbolNo special character found
password_recently_usedPassword matches one of the last N hashes (history_count)

Best Practices

Prefer length over complexity

NIST SP 800-63B recommends prioritizing password length (12+ characters) over complex character requirements. Long passphrases are easier to remember and harder to crack than short complex passwords. Consider setting a high min_length while leaving the require_* fields disabled.

Use history_count to prevent cycling

Without password history, users forced to rotate passwords will often just increment a number (Password1 → Password2). Set history_count: 12 or higher to make cycling impractical. This is especially important when max_age_days is active.

Show requirements in real-time

Fetch the policy from the public endpoint and validate as users type. This prevents frustrating round-trips where users only learn about requirements after submitting. Mark each requirement with a visual indicator (checkmark/cross) for instant feedback.

Consider disabling max_age_days

NIST SP 800-63B (2020 revision) advises against mandatory periodic password changes — they lead to weaker passwords and predictable patterns. Only enable rotation if compliance requires it (PCI DSS, HIPAA). For most applications, keep max_age_days: 0 and rely on breach detection instead.

What's Next

We use cookies and similar technologies to measure traffic and improve the site. You can choose which categories to allow. Manage Preferences.