Skip to main content

Password policy levels

OpenTranscribe has four password levels, chosen in Settings -> Authentication -> Local (a four-card picker that previews the exact rules before you save) or with PASSWORD_POLICY_PROFILE:

LevelForRules
basicLocal development, small teams8 characters, no composition rules, no expiry, history or minimum age
standardMost installs (recommended)NIST SP 800-63B-4: long passphrases, no composition rules, no forced rotation, breached-password screening
hardenedGovernment, DoD, air-gappedDoD STIG / FedRAMP style: 12 characters, complexity, 60-day rotation, history of 24
customAnything elseExactly the individual PASSWORD_* values you set

nist and stig still work everywhere as aliases of standard and hardened.

Trade-off. Lighter levels are easier on users but accept weaker passwords. standard follows current NIST guidance: length plus a breached-password check protects better than complexity rules and scheduled changes, which push people toward predictable patterns. hardened keeps complexity, expiry and history for compliance regimes that still require them. basic is opt-in only and is not recommended for anything exposed to the internet.

Which level do I get?​

  • Fresh installs (.env created from .env.example by setup-opentranscribe.sh) get PASSWORD_POLICY_PROFILE=standard.
  • Existing installs have no such line in their .env, so the code default hardened applies and nothing changes on upgrade. To move to standard, pick it in the admin UI or add PASSWORD_POLICY_PROFILE=standard to .env and restart. Existing passwords keep working; existing expiry dates stop being enforced; new passwords are checked against the new rules.
  • Some DoD/agency customers mandate the STIG values. Those deployments should set PASSWORD_POLICY_PROFILE=hardened explicitly so the choice is recorded.

Precedence is unchanged: admin UI (auth_config table) > .env > coded default.

Settings​

Settingbasicstandardhardenedcustom
Minimum length815; 8 if the account is MFA-protectedPASSWORD_MIN_LENGTH (12)PASSWORD_MIN_LENGTH
Maximum length (PASSWORD_MAX_LENGTH, 0 = default)128, a configured value is raised to at least 64same as basicnoneas configured
Composition (PASSWORD_REQUIRE_*)neverneverupper, lower, digit, specialas configured
Unicode / spacesall accepted, NFKC-normalisedsameacceptedaccepted
Expiry (PASSWORD_MAX_AGE_DAYS)nevernever (change on evidence of compromise: admin reset, must_change_password)60 daysas configured
History (PASSWORD_HISTORY_COUNT), minimum age (password_min_age_hours)offoff24 / 24 has configured
Blocklist + context wordson if a list is installedonoffoff
Account lockoutunchanged: ACCOUNT_LOCKOUT_*unchangedunchangedunchanged

PASSWORD_BLOCKLIST_ENABLED=true|false overrides the level default in any level. PASSWORD_POLICY_ENABLED=false still disables the whole policy.

An unrecognised PASSWORD_POLICY_PROFILE value is logged and treated as hardened.

In the admin UI​

Under basic and standard the individual fields are hidden, because the level fixes those rules. hardened shows its values read-only; choose custom to edit them. In custom the server enforces bounds on save (minimum length 8-128, history 0-100, expiry 0-3650 days, minimum age 0-8760 hours, and a maximum length that is not below the minimum); an operator can still set a lower minimum with PASSWORD_MIN_LENGTH. The page also controls the breached-password check (level default / on / off), shows whether the list is installed, and has the optional online lookup switch and Require MFA for administrators. Every change is written to the audit log.

"MFA-protected" (the 8-character floor)​

Under standard the minimum is 8 instead of 15 when the account has TOTP enrolled, or MFA is mandatory for it (MFA_REQUIRED, or MFA_REQUIRED_FOR_ADMINS for an admin). This is applied on password change and reset and on admin-set passwords. New registrations and invitation acceptance use 15, because the account has no second factor yet.

Unicode and normalisation​

Passwords are NFKC-normalised before length counting, blocklist comparison and hashing, so the same visible passphrase typed with composed vs decomposed accents (or full-width forms) is one password. Hashes created before this change were computed over the raw string; login tries the normalised form first and the raw form second, so no existing user is locked out. Nothing is truncated: the hash schemes in use (PBKDF2-SHA256, bcrypt-SHA256) have no 72-byte limit.

Breached / common password screening​

When enabled, a new password is rejected if, after NFKC + case folding, it:

  1. is on the offline blocklist (when installed), or
  2. contains a context word of 4+ characters: the product name, any word in PASSWORD_CONTEXT_WORDS (comma-separated, e.g. your organisation), the account's email local part, or a part of the account's full name.

Existing weak-pattern warnings (password123, sequences, repeats) are unchanged.

Offline list (installed on request)​

OpenTranscribe does not ship a password list. Install one with a single command:

./opentranscribe.sh download-models password-blocklist

This builds the 100,000 most-breached passwords from Have I Been Pwned "Pwned Passwords" as SHA-1 hashes only (uppercase hex, one per line, most common first) in <MODEL_CACHE_DIR>/password-blocklist/pwned-passwords-top100k-sha1.txt, plus a small .meta.json recording the retrieval date. It walks the official k-anonymity range API (1,048,576 small requests; expect a few hours and roughly 35 GB of transfer, resumable if interrupted), then keeps the top 100,000 hashes by breach count. Re-run it to refresh. Nothing is sent but 5-character hash prefixes, and no plaintext password is requested or stored.

Data source and terms. Pwned Passwords is published by Have I Been Pwned (Troy Hunt). Its documentation states "no licensing or attribution requirement" (API v3, Passwords, checked 2026-10-01); we credit it anyway as a courtesy.

Matching: the password is NFKC-normalised and its SHA-1 compared. Because the hashes are case-exact, the lowercased and case-folded forms are checked too, so PASSWORD is caught by the entry for password.

Air-gapped installs. scripts/build-offline-package.sh builds the list into the package (set INCLUDE_PASSWORD_BLOCKLIST=false to skip it) and install-offline-package.sh installs it under models/password-blocklist/. Or build it on a connected machine and copy that directory.

If no list is installed the check is skipped, one startup WARNING names the command above, and the admin Settings page shows "breached-password list not installed". The context-word check still applies. Nothing is fetched at runtime unless the optional online lookup below is on.

Your own list. Set PASSWORD_BLOCKLIST_PATH to a file of SHA-1 hashes (40 hex characters per line, optionally HASH:count) or a legacy plaintext file (one password per line, matched case-insensitively); the format is detected per line. It replaces the default list; edits are picked up by file modification time without a restart. An unreadable path logs an ERROR and falls back to the default location. The whole list is held in memory (100k hashes is roughly 10 MB).

To regenerate with different options: docker compose run --rm backend python -m app.scripts.build_password_blocklist --help.

Optional online lookup (default OFF)​

PASSWORD_HIBP_ENABLED=true adds a Have I Been Pwned-style range query (PASSWORD_HIBP_URL, default https://api.pwnedpasswords.com/range). Only the first five hex characters of the password's SHA-1 are sent (k-anonymity) with the Add-Padding header; the password and the full hash never leave the host. Leave it off for air-gapped installs: it is the only outbound call in the policy.

Fail-open for availability. If the service times out (PASSWORD_HIBP_TIMEOUT_SECONDS, default 3), is unreachable, returns a non-200 or garbage, the password is accepted and a WARNING ("Breached-password range lookup ... failing open") is logged. A third-party outage must not stop users registering or recovering accounts. The offline list is therefore the baseline control, and the online lookup an additional one. The lookup only runs once every other check has passed.

MFA for administrators​

MFA_REQUIRED_FOR_ADMINS (default false): when true and MFA_ENABLED=true, any user with role admin or super_admin must enrol TOTP at next login and verify it on every login, even if MFA_REQUIRED=false. Recommended true for every deployment that enables MFA; it defaults to false only so an upgrade cannot lock an admin out before they have enrolled. It has no effect while MFA_ENABLED=false. Users authenticated by an external IdP (OIDC/PKI/SAML) are subject to the same IdP-owns-the-second-factor exemptions as MFA_REQUIRED.

WebAuthn / passkeys (phishing-resistant, AAL2/AAL3) are a planned follow-up and are not part of this change.

Mapping​

NIST SP 800-63B-4 (section 3.1.1)​

RequirementWhere
Min 15 characters single-factor; 8 with MFAstandard level, min_length_for()
Accept at least 64 characters; no truncationPASSWORD_MAX_LENGTH floor 64 (default 128); no hash truncation
Accept all printing ASCII, space, Unicode; normaliseno character-class filter; NFKC
SHALL NOT impose composition rulesPASSWORD_REQUIRE_* ignored under standard
SHALL NOT require periodic change; change on compromisePASSWORD_MAX_AGE_DAYS ignored under standard; admin reset / must_change_password
SHALL check against a blocklist (breached, dictionary, context words)offline list, context words, optional range lookup
Rate-limit failed attemptsACCOUNT_LOCKOUT_*
Multi-factor for privileged accountsMFA_REQUIRED_FOR_ADMINS

DISA STIG / FedRAMP IA-5 (hardened level)​

ControlSetting
Minimum length 12-15PASSWORD_MIN_LENGTH (12; raise to 15 for the current DoD value)
Upper / lower / digit / specialPASSWORD_REQUIRE_UPPERCASE/LOWERCASE/DIGIT/SPECIAL
Maximum lifetime 60 daysPASSWORD_MAX_AGE_DAYS
Minimum lifetime 24 hourspassword_min_age_hours
Reuse prevention (24)PASSWORD_HISTORY_COUNT
Lockout 3-5 attemptsACCOUNT_LOCKOUT_THRESHOLD / _DURATION_MINUTES