OTP policy (2FA enforcement)
RFC 6238 time-based one-time passwords with an org-wide enforcement policy. Seeds are envelope-encrypted; recovery codes are bcrypt-hashed and one-shot. This page deepens the TOTP summary on the Auth & RBAC page.
TOTP seeds are protected with envelope encryption. If no KEK is configured (encryption.kek / CERTAUTOPILOT_ENCRYPTION_KEK), the TOTP endpoints are not wired at all and 2FA is unavailable.
Policy modes
Settings → OTP Policy (requires the owner org role). Exactly two modes, stored as otp_policy:
| Mode | UI label | Effect |
|---|---|---|
optional | Optional | Users may enroll from their profile; nothing is enforced. Default. |
must | Required | Every user must have TOTP. Non-enrolled users are forced through setup on their next login and cannot use the app until they finish. |
There is no per-user override and no role-scoped mode (e.g. "admins only") — the policy is org-wide, binary. Individual users opt in voluntarily under optional.
Setting the policy to Required needs an Enterprise license. On license downgrade there is a 7-day grace period; an existing Required policy set while licensed stays enforced afterwards — you just can't set it again until you re-upgrade. Voluntary enrollment works on every plan.
Enrollment
Self-service from the profile, or forced at login when the policy is Required:
- Start setup (
POST /auth/totp/setup). The backend generates a seed (issuerCertAutoPilot, account = your username) and returns anotpauth://URL rendered as a QR code. Scan it with any RFC 6238 app (Google Authenticator, Authy, 1Password, Bitwarden). A pending, unconfirmed seed is reused if you retry within 30 minutes — re-opening the dialog doesn't invalidate the QR you already scanned. - Confirm with the current 6-digit code (
POST /auth/totp/verify-setup). This proves the device is in sync and enables 2FA. - Save the 8 recovery codes returned with the confirmation — format
XXXX-XXXX, drawn from a charset without ambiguous characters (noI,O,0,1).
Only bcrypt hashes are stored. Each code works exactly once (login responses report how many remain). Regenerate a fresh set from your profile (POST /auth/totp/recovery-codes) — this requires a current TOTP code and invalidates all previous codes.
Login flow
After a correct password, the server decides:
- User has TOTP enabled → the response carries
otp_required: trueplus a short-lived OTP session ID; the UI prompts for the code (or a recovery code) and submits it toPOST /auth/otp/verify. - Policy is
mustand user is not enrolled → the response carriesotp_setup_required: trueplus a setup-purpose session; the UI forces the enrollment flow before issuing any tokens. There is no grace login. - Policy
optional, user not enrolled → normal login.
OTP sessions are valid for 5 minutes and allow 5 attempts; the verify endpoint is additionally rate-limited to 10 requests per minute. A failed code returns a generic error — no distinction from a wrong password.
Validation details
- Library defaults per RFC 6238: HMAC-SHA-1, 6 digits, 30-second period, ±1 period of clock-skew tolerance.
- The encrypted seed (
totp_encrypted_secret) carries its ownkek_version, so KEK rotation covers TOTP seeds like every other envelope-encrypted field.
Disabling 2FA
Self-service only (POST /auth/totp/disable):
- Local users — current password and a valid TOTP code or recovery code.
- LDAP users — a valid TOTP code or recovery code (no password check; their password lives in the directory).
No API or UI exists for an admin to clear another user's TOTP enrollment. A user who loses both the device and all recovery codes cannot recover 2FA through the product — treat recovery codes as mandatory, and consider keeping at least one break-glass local admin account with codes in a sealed store.
Lockout & rate limits
Two separate mechanisms:
- Password lockout (per account): after 10 failed password attempts the account locks with a progressive duration — 1 minute at 10 failures, 5 minutes at 20, 15 minutes at 30, 1 hour at 40+. The failure counter resets after 15 minutes without a failed attempt, and on any successful authentication. Locked-out logins return the same generic "invalid username or password".
- OTP attempts do not feed the account lockout — they burn the OTP session's 5 attempts (then the session dies and you must log in again) and are capped by the 10/minute rate limit on the verify endpoint.
LDAP users
TOTP is fully supported for LDAP-federated users: enrollment, recovery codes and the org policy apply identically, and the directory never sees the OTP code. Only the disable flow differs (see above).
Troubleshooting
"Code invalid" right after enrollment
Clock skew beyond ±30 s. Both the authenticator device and the backend hosts must be on NTP; desktop authenticator apps drift more often than phones.
Prompted to set up 2FA at login, can't skip
The org policy is Required. That's the designed behavior — enrollment is the only way forward. If this surprised you, an owner set the policy under Settings → OTP Policy.
"Required" radio is greyed out
Mandatory enforcement needs an Enterprise license (the option shows an Enterprise tag). Voluntary enrollment still works.
OTP prompt expired mid-login
OTP sessions live 5 minutes / 5 attempts. Restart the login; nothing is locked unless the password lockout separately triggered.