Backend
Two factor authentication
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.
123456Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /auth/mfa | {enabled, confirmed_at, backup_codes_remaining} |
| POST | /auth/mfa/setup | Starts (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.
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 fromSECRET_KEY; secrets encrypted under a key inSECRET_KEY_FALLBACKSstay readable, so keys can rotate. - Backup codes are stored hashed and work once. Using one sends
backup_code_usedand is recorded in the activity log. - Enabling and disabling 2FA email a security notice. The data export includes the state and dates, never secrets.
verifyis CSRF protected and throttled with theauthscope. Each code attempt is counted before it is checked, so parallel guesses cannot exceed the limits.setup,confirm,disableandbackup-codesneed a session: an API key gets 403api_key_not_allowed.
Settings
PLEATE["MFA"]:
| Key | Default | Meaning |
|---|---|---|
| ISSUER | None | Name shown in authenticator apps. Falls back to SITE_NAME. |
| BACKUP_CODES | 10 | How many backup codes are generated. |
| PENDING_LOGIN_SECONDS | 300 | Time to type the code after the password. |
| ENCRYPTION_KEY | None | A Fernet key, or any string stretched with HKDF. Defaults to a key derived from SECRET_KEY. |
Error codes
| Code | Status | When |
|---|---|---|
| mfa_already_enabled | 409 | setup or confirm when 2FA is already on. |
| mfa_setup_required | 400 | confirm without a started enrolment. |
| mfa_not_enabled | 400 | disable when 2FA is off. |
| invalid_code | 400 | Wrong code. On verify the body also has attempts_left. |
| wrong_password | 400 | disable with the wrong password. |
| code_required | 400 | Neither a code nor a backup code was sent. |
| mfa_pending_expired | 400 | No pending login, expired, or five wrong codes. |
| too_many_attempts | 429 | Too 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.