Skip to main content

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.

Requires a configured KEK

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:

ModeUI labelEffect
optionalOptionalUsers may enroll from their profile; nothing is enforced. Default.
mustRequiredEvery 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.

Required mode is an Enterprise feature

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:

  1. Start setup (POST /auth/totp/setup). The backend generates a seed (issuer CertAutoPilot, account = your username) and returns an otpauth:// 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.
  2. Confirm with the current 6-digit code (POST /auth/totp/verify-setup). This proves the device is in sync and enables 2FA.
  3. Save the 8 recovery codes returned with the confirmation — format XXXX-XXXX, drawn from a charset without ambiguous characters (no I, O, 0, 1).
Recovery codes are shown once

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: true plus a short-lived OTP session ID; the UI prompts for the code (or a recovery code) and submits it to POST /auth/otp/verify.
  • Policy is must and user is not enrolled → the response carries otp_setup_required: true plus 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 own kek_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).
There is no admin-side OTP reset

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.

See also