Backend

Two factor authentication

TOTP authenticator apps and single use backup codes, as a second step of the password and OAuth logins.

pleate_backend.mfa adds TOTP codes (Google Authenticator, 1Password, Authy...) and single use backup codes, mounted at /api/auth/mfa. Every endpoint needs a signed in user, except verify, which is the second step of a login.

Enrolment

Scan the QR code, type a code, save the backup codes. useTwoFactorSetup provides each step.

Mock backendThe mock accepts the code 123456

Endpoints

MethodPathPurpose
GET/auth/mfa{enabled, confirmed_at, backup_codes_remaining}
POST/auth/mfa/setupStarts (or restarts) enrolment → {secret, otpauth_url, qr_svg}.
POST/auth/mfa/confirm{code}, the first code from the app. Turns 2FA on → {backup_codes}, shown only once.
POST/auth/mfa/disable{password, code}. password only when the account has one; code is a TOTP or a backup code. 204.
POST/auth/mfa/backup-codes{code} → a fresh set of {backup_codes}, replacing the old ones.
POST/auth/mfa/verify{code} or {backup_code} after a login that answered mfa_required → {user, mfa_required: false}.

The login step

When a user with 2FA signs in with the right password, POST /auth/login does not create a session. It answers {"mfa_required": true, "methods": ["totp", "backup_code"]} and keeps a pending login in the session for PENDING_LOGIN_SECONDS. The client then posts the code to /auth/mfa/verify. OAuth logins take the same path: the callback redirects with status=mfa_required.

After five wrong codes, or when the time runs out, the pending login is dropped (mfa_pending_expired) and the user starts again from the password. useLogin handles this by switching back to the first form.

tsx
const login = useLogin({ onSuccess: (user) => router.push("/") });

return login.mfaRequired
  ? <TwoFactorForm {...login.twoFactorFormProps} />   // posts {code} or {backup_code}
  : <LoginForm {...login.loginFormProps} />;

Security

  • TOTP secrets are encrypted at rest with Fernet. The key is ENCRYPTION_KEY, or derived from SECRET_KEY; secrets encrypted under a key in SECRET_KEY_FALLBACKS stay readable, so keys can rotate.
  • Backup codes are stored hashed and work once. Using one sends backup_code_used and is recorded in the activity log.
  • Enabling and disabling 2FA email a security notice. The data export includes the state and dates, never secrets.
  • verify is CSRF protected and throttled with the auth scope. Each code attempt is counted before it is checked, so parallel guesses cannot exceed the limits.
  • setup, confirm, disable and backup-codes need a session: an API key gets 403 api_key_not_allowed.

Settings

PLEATE["MFA"]:

KeyDefaultMeaning
ISSUERNoneName shown in authenticator apps. Falls back to SITE_NAME.
BACKUP_CODES10How many backup codes are generated.
PENDING_LOGIN_SECONDS300Time to type the code after the password.
ENCRYPTION_KEYNoneA Fernet key, or any string stretched with HKDF. Defaults to a key derived from SECRET_KEY.

Error codes

CodeStatusWhen
mfa_already_enabled409setup or confirm when 2FA is already on.
mfa_setup_required400confirm without a started enrolment.
mfa_not_enabled400disable when 2FA is off.
invalid_code400Wrong code. On verify the body also has attempts_left.
wrong_password400disable with the wrong password.
code_required400Neither a code nor a backup code was sent.
mfa_pending_expired400No pending login, expired, or five wrong codes.
too_many_attempts429Too many codes tried in a short time.

Signals and extras

pleate_backend.mfa.signals sends mfa_enabled, mfa_disabled and backup_code_used with user and request. /auth/me gets extra.mfa_enabled.