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.
| Field | Type | Range | Default | Description |
|---|---|---|---|---|
| min_length | integer | 8–128 | 8 | Minimum number of characters |
| require_uppercase | boolean | — | false | At least one uppercase letter (A–Z) |
| require_lowercase | boolean | — | false | At least one lowercase letter (a–z) |
| require_numbers | boolean | — | false | At least one digit (0–9) |
| require_symbols | boolean | — | false | At least one symbol (!@#$%^&*...) |
| max_age_days | integer | 0–365 | 0 | Force password change after N days (0 = disabled) |
| history_count | integer | 0–24 | 0 | Remember 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:
/api/v1/settings🔒 AuthConfigure password policy for the tenant.
{
"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.
/public/password-policy/:slugPublicFetch the password policy for a tenant (no auth required).
Real-Time Validation Example
Here's a complete JavaScript implementation showing how to integrate client-side validation with a password input field:
// 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
- 1User logs in with correct email + password.
- 2Server checks
password_changed_at + max_age_daysagainst current time. - 3If expired, the response includes
password_expired: trueand a temporarypassword_change_tokeninstead of access tokens. - 4Your app redirects to a "change password" screen. Use the temporary token to call
POST /api/v1/auth/change-password. - 5After successful password change, the server issues normal access + refresh tokens.
Expired Password Response
{
"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_tokenis 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_atfrom 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
422with the reasonpassword_recently_used. - •History is per-user and persists across password resets — you can't bypass history by using "forgot password".
- •If you increase
history_countfrom 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.
{
"error": "password_policy_violation",
"details": ["too_short", "missing_symbol"]
}Possible Detail Codes
| Code | Meaning |
|---|---|
| too_short | Password is shorter than min_length |
| missing_uppercase | No uppercase letter found |
| missing_lowercase | No lowercase letter found |
| missing_number | No digit found |
| missing_symbol | No special character found |
| password_recently_used | Password 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
Tenant Settings Guide →
Full guide to configuring all tenant settings including security policies.
Authentication API →
Reference for login, password change, and password reset endpoints.
Public Endpoints →
All unauthenticated endpoints including password-policy and tenant info.
MFA Guide →
How MFA interacts with password expiry and the complete multi-factor flow.