Documentation menu

Two-Factor Authentication (TOTP)

Citadel and Citadel Cloud both support time-based one-time passwords (TOTP) as a second factor on top of your password. Once enrolled, sign-in requires both your password and a 6-digit code from your authenticator app — a stolen password alone is no longer enough to take over your account.

Apps that work

Any RFC 6238 TOTP authenticator works. Common choices:

  • 1Password / Bitwarden (recommended — your codes are backed up with your vault)
  • Google Authenticator, Microsoft Authenticator, Authy
  • 2FAS (open source, has a desktop companion)

The QR codes Citadel issues are standard otpauth://totp/... URIs.

Enroll

You enroll separately for each surface you sign into:

  • Citadel desktop / dashboard (the local controller) — enroll from Settings → Security → Two-Factor Authentication.
  • citadels.cc (the Cloud account portal) — enroll from Account → Security.

The flow is the same on both sides: the server hands you a secret + QR code, you scan it into your authenticator, then you confirm by typing a fresh 6-digit code back into Citadel. Only after that confirmation does the secret get committed and 2FA become required for future sign-ins. If you bail out after the QR step but before confirming, the half-enrolled secret is harmless and gets overwritten the next time you start enrollment.

Backup codes

When 2FA is successfully enrolled, you're shown a one-time list of recovery codes — usually ten. Each is single-use and can be typed in place of a TOTP code if you lose access to your authenticator (phone stolen, factory reset, lost during travel, etc.).

These codes are shown exactly once. Print them, save them in your password manager, or stick them in a safe. If you lose both your authenticator and your backup codes, you're locked out — recovery requires support intervention with proof of account ownership.

You can regenerate the codes anytime from the same Security page (you'll be asked for a fresh TOTP code first to prove you still have the second factor — backup codes can't be used to regenerate themselves). Regenerating invalidates the old set entirely.

Sign in with 2FA enabled

After entering email + password the API returns a short-lived 2FA challenge token (5 minutes) and a needs2fa: true flag instead of a session cookie. The UI prompts for a code; you enter either:

  • A current TOTP code from your authenticator, or
  • One of your backup codes (it'll be consumed and won't work again).

The challenge token + code are then exchanged for the actual session cookie. If you don't complete the exchange within 5 minutes, you start over.

Disable 2FA

You can disable 2FA from the Security page by submitting a current TOTP code (backup codes are also accepted here). The TOTP secret and any unused backup codes are wiped. This is not a "forgot my code" recovery — if you've already lost your second factor, this path won't help; contact support.

How the secret is stored

The TOTP shared secret is encrypted at rest using ENCRYPTION_KEY from your .env. Backup codes are individually hashed with bcrypt — the stored value is the digest, not the code, so a leaked database snapshot can't be used to forge a recovery. If ENCRYPTION_KEY ever changes after enrollment, the existing secret becomes unreadable; the API surfaces this as a clear "two-factor secret could not be read — contact support" error rather than silently logging the user out, so an operator can recover via backup codes or admin reset.

Lockouts and 2FA

Failed 2FA codes count toward the same per-(IP, username) fail2ban counter as failed passwords. Six wrong codes from the same IP within ten minutes triggers an escalating lockout (60s → 5min → 1h). A successful sign-in clears the counter. This means a stolen 2FA challenge token isn't a free pass to brute-force the code from a single IP — the lockout kicks in fast.

API reference

These endpoints power the dashboard's 2FA UI; you can also call them directly from a custom client (e.g. for headless enrollment). All require an existing logged-in session unless noted.

Citadel (controller)

MethodPathAuthDescription
POST/api/auth/mfa/setupRequiredReturns { secret, otpauthUri }. Stashes a pending secret on the user record but leaves 2FA disabled until verified.
POST/api/auth/mfa/verifyRequiredBody { code }. Confirms enrollment, flips the bit, and returns one-time backup codes.
POST/api/auth/mfa/disableRequiredBody { code }. Disables 2FA.

Citadel Cloud (citadels.cc API)

MethodPathAuthDescription
POST/api/v1/auth/2fa/setupSession + current passwordReturns { secret, otpauthUri }. Requires fresh password re-auth so a stolen session can't silently rotate a pending secret.
POST/api/v1/auth/2fa/verify-enrollmentSessionBody { code }. Confirms enrollment and returns backup codes.
POST/api/v1/auth/2fa/disableSessionBody { code }. Disables 2FA. TOTP code or backup code accepted.
POST/api/v1/auth/2fa/regenerate-backup-codesSessionBody { code }. TOTP code only (backup codes refused).
POST/api/v1/auth/login/2faNone (challenge token in body)Body { twoFactorToken, code }. Completes a sign-in started by /login when the account has 2FA enabled.

Turn on 2FA for any account that can change billing, manage other users, or run server-control actions. The setup is two minutes and the protection against credential stuffing is worth it.