MFA / 2FA with TOTP (Authenticator)¶
Since v0.35.0 the bundled auth flow supports two-factor authentication with Authenticator apps (Google Authenticator, 1Password, Authy, etc.) following the TOTP (RFC 6238) standard. You get four ready-to-mount endpoints, single-use recovery codes, and a two-step login — all behind a global kill-switch.
What's in this recipe¶
- How it works in 30 seconds — the mental model of the two-step flow.
- Setup — the
[mfa]extra, newUserModelcolumns, recovery-code table. - Wiring — passing
recovery_code_modeltomake_auth_router. - The four endpoints — enroll / confirm / verify / disable.
- Two-step login — how
POST /auth/loginchanges when MFA is active. - Routes that require MFA — the
amrclaim andmake_mfa_required_dependency. - Settings (
AuthSettings) — flag by flag. - Attempt limit —
429after five failures, and single-use TOTP codes. - Using just
UserAuthService(no router) — for hand-rolled endpoints. - Accounts that do not use emailed links —
refuses_email_linkfor operators and staff. - Security.
- Next steps.
How it works¶
TOTP is the 6-digit code that rolls over every 30 seconds in your Authenticator app. The server and the app share a secret (generated at enrollment); both derive the same code from the current clock. No SMS, no network — pure local math.
The flow has two moments:
- Enrollment (once) — the logged-in user requests a secret, scans the QR code, and confirms by typing the first code. From there MFA is active.
- Login (every time) — the password validates step 1, but instead of the JWT pair the backend returns a short-lived
mfa_token. The user types the Authenticator code; step 2 swapsmfa_token+ code for the real JWT pair.
Why two steps and not all at once?
Splitting keeps the password and the second factor decoupled. The mfa_token (5-min TTL by default) carries only the user's sub — intercepting it alone is not enough to log in, because the Authenticator code is still missing.
Setup¶
Requires the [mfa] extra (installs pyotp), on top of [auth]:
Columns via MFAMixin¶
The MFA columns (totp_secret, totp_enabled_at, totp_last_step) do not live on BaseUserModel — they come from an opt-in mixin, MFAMixin. Mix it into your UserModel only when you adopt MFA, so projects that never enable the feature carry no dead columns:
# src/db/models/user.py
from tempest_fastapi_sdk import BaseUserModel, MFAMixin
class UserModel(MFAMixin, BaseUserModel):
"""Concrete user table — MFAMixin adds the totp_* columns."""
__tablename__ = "users"
MRO order
The mixin comes before BaseUserModel in the base list — same pattern as AuditMixin / SoftDeleteMixin. The mixin also exposes an is_mfa_active property (totp_enabled_at is not None).
Migration required
totp_secret, totp_enabled_at and totp_last_step are new columns. Run uv run tempest db revision -m "mfa columns" + uv run tempest db upgrade before flipping the flag.
Recovery-code table¶
Recovery codes save the user who lost their phone. They are single-use, shown once at enrollment, and the database stores only the SHA-256 hash of each. BaseUserRecoveryCodeModel is abstract — use the make_user_recovery_code_model helper to build the concrete table bound to your users table:
# src/db/models/__init__.py
from tempest_fastapi_sdk import make_user_recovery_code_model
from src.db.models.user import UserModel
from src.db.models.user_token import UserTokenModel
UserRecoveryCodeModel = make_user_recovery_code_model(
user_table="users",
tablename="user_recovery_codes",
class_name="UserRecoveryCodeModel",
)
__all__: list[str] = [
"UserModel",
"UserTokenModel",
"UserRecoveryCodeModel",
]
Prefer subclassing by hand?
The helper is just sugar. The explicit equivalent:
from uuid import UUID
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import BaseUserRecoveryCodeModel
class UserRecoveryCodeModel(BaseUserRecoveryCodeModel):
__tablename__ = "user_recovery_codes"
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
Wiring¶
Turn on the AUTH_MFA_ENABLED flag and pass the recovery_code_model to the router. Without the model, the router raises RuntimeError at build time — a deliberate guard:
# src/api/app.py
from fastapi import FastAPI
from tempest_fastapi_sdk import AsyncDatabaseManager, UserAuthService, make_auth_router
from src.core.settings import settings
from src.db.models import UserModel, UserRecoveryCodeModel, UserTokenModel
app = FastAPI()
db = AsyncDatabaseManager(settings.DATABASE_URL)
auth_service = UserAuthService(
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=settings, # mixes in AuthSettings (AUTH_MFA_* below)
jwt_settings=settings,
email=None,
)
app.include_router(
make_auth_router(
auth_service,
session_factory=db.session_dependency,
recovery_code_model=UserRecoveryCodeModel, # required when MFA is on
),
)
Global kill-switch
With AUTH_MFA_ENABLED=False (default), the /auth/mfa/* endpoints respond 404 and login ignores any persisted totp_secret — handy to disable MFA during an Authenticator outage without touching the database.
Endpoints¶
The four are only mounted when AUTH_MFA_ENABLED=True:
| Method | Path | Auth | Body / Output | Behavior |
|---|---|---|---|---|
| POST | /auth/mfa/enroll |
Bearer JWT | — → MFAEnrollResponseSchema |
Generates secret + QR URI + N recovery codes. Shown only once. Does NOT activate MFA yet. With MFA already active, answers 409 (MFA_ALREADY_ENROLLED) and changes nothing. |
| POST | /auth/mfa/confirm |
Bearer JWT | MFAConfirmSchema → LoginResponseSchema |
Confirms enrollment with the first code. From here MFA is active. Revokes the user's refresh tokens and returns a new pair carrying the MFA amr (details). |
| POST | /auth/mfa/verify |
— | MFAVerifySchema → LoginResponseSchema |
Login step 2: swaps mfa_token + code for the JWT pair. Five wrong codes per account in 15 min give 429 (attempt limit). |
| POST | /auth/mfa/disable |
Bearer JWT | MFADisableSchema |
Disables MFA. Requires password and an active code (TOTP or recovery). |
Enrollment flow¶
import httpx
BASE = "http://localhost:8000"
access = "<logged-in user's JWT>"
headers = {"Authorization": f"Bearer {access}"}
# 1. Enroll — returns secret, QR URI and the recovery codes (once!)
r = httpx.post(f"{BASE}/auth/mfa/enroll", headers=headers)
data = r.json()
print(data["provisioning_uri"]) # render as a QR code
print(data["recovery_codes"]) # show the user — save OFFLINE
# 2. User scans the QR in the Authenticator and types the generated code:
code = input("Authenticator code: ")
httpx.post(f"{BASE}/auth/mfa/confirm", headers=headers, json={"code": code})
# 200 → MFA active; the body carries a new pair, and the old refresh token is dead
Recovery codes appear ONCE
The enroll response is the only time secret and recovery_codes leave in plaintext. Show them prominently and tell the user to store them offline.
Rotating the secret: disable, then enroll¶
Calling enroll again before confirm replaces the pending secret and codes: the enrollment is not active yet, so there is no factor to protect. After confirm, enroll refuses with 409 and touches nothing:
{"detail":"MFA is already active — disable it before enrolling again","code":"MFA_ALREADY_ENROLLED","details":{}}
That is the body with register_exception_handlers mounted. The refusal exists because enroll only asks for the bearer token and wipes the recovery codes: accepting a second enroll over active MFA let whoever held just the access token switch the second factor off, without the password and the code disable demands. To rotate the secret (new phone, leaked secret), the user goes through POST /auth/mfa/disable with password + code and then redoes enroll + confirm. In the service, the same refusal is the MFAAlreadyEnrolledException exception (a ConflictException subclass), raised by mfa_enroll even with the AUTH_MFA_ENABLED kill-switch off.
Two-step login¶
When the user has MFA active, POST /auth/login no longer returns the JWT pair directly — it returns mfa_required=True + a short-lived mfa_token:
import httpx
BASE = "http://localhost:8000"
# Step 1 — password
r1 = httpx.post(
f"{BASE}/auth/login",
json={"email": "ana@example.com", "password": "strong-pass-12-chars"},
)
body = r1.json()
# {
# "user_id": "...",
# "access_token": null,
# "refresh_token": null,
# "mfa_required": true,
# "mfa_token": "eyJhbGciOi..."
# }
# Step 2 — Authenticator code (or a recovery code)
code = input("Authenticator code: ")
r2 = httpx.post(
f"{BASE}/auth/mfa/verify",
json={"mfa_token": body["mfa_token"], "code": code},
)
tokens = r2.json()
# { "access_token": "...", "refresh_token": "...", "mfa_required": false }
For users without MFA (or with the kill-switch off), POST /auth/login keeps returning the JWT pair directly, with mfa_required=False — the frontend just checks that field and branches.
sequenceDiagram
participant F as Frontend
participant API as Backend (SDK)
participant DB as Database
F->>API: POST /auth/login (email + password)
API->>DB: validate credentials
alt user has MFA active
API->>F: 200 {mfa_required: true, mfa_token: "..."}
F->>API: POST /auth/mfa/verify (mfa_token + code)
API->>DB: validate TOTP or recovery code
API->>F: 200 {access_token, refresh_token}
else MFA off / not enrolled
API->>F: 200 {access_token, refresh_token, mfa_required: false}
end
Routes that require MFA¶
Turning MFA on protects the login. A sensitive route (creating another
staff member, changing bank details) wants to know something else: did this
session pass the second factor? For that, every access token the SDK issues
carries the amr claim (RFC 8176):
| How the session started | amr |
|---|---|
POST /auth/login with the password alone (and signup with auto-activation) |
["pwd"] |
POST /auth/activate/{token} and POST /auth/password-reset/confirm of an account without MFA (v0.314.0+) |
["email"] (AMR_EMAIL_LINK) |
POST /auth/mfa/verify (TOTP code or recovery code) |
the first step + "otp": ["pwd", "otp"] after /auth/login, ["ext", "otp"] after a social login, ["email", "otp"] after an activation or a reset |
POST /auth/mfa/confirm (the new pair it returns) |
["pwd", "otp"] |
POST /auth/webauthn/authenticate/complete |
["hwk"] |
social login (/auth/oauth/...) of an account without MFA |
["ext"] |
On an account with MFA enrolled, /auth/password-reset/confirm and
/auth/activate/{token} (for a reactivated account) do not issue a pair:
they answer the mfa_required=true + mfa_token challenge, and the pair
only comes out of /auth/mfa/verify, with amr == ["email", "otp"]. It is
the same treatment as /auth/login — a compromised mailbox must not be a
shortcut past the second factor. Since v0.314.0 social login follows the same
rule: the callback and /auth/oauth/{provider}/token of an account with TOTP
answer the challenge, and /auth/mfa/verify issues ["ext", "otp"] (see the
social login recipe).
Why email, and neither pwd nor otp
Activation takes no password at all, and the reset proves the mailbox
before it stores the new password — pwd would claim a check that
never ran. email sits outside the RFC 8176 registry, like ext, because
the RFC has no value for "single-use link sent by email". And it is not
otp on purpose: the link is one factor, and calling it a one-time
password would let it pass make_mfa_required_dependency. It stays out of
MFA_AMR_METHODS, so the gate keeps refusing it.
refresh repeats the family's amr — it is stored on the refresh-token row
(or inside the refresh JWT itself, in stateless mode) —, so a rotation never
promotes a password session to MFA. make_mfa_required_dependency reads that
claim:
# src/api/routers/staff.py
from typing import Any
from fastapi import APIRouter, Depends
from tempest_fastapi_sdk import JWTUtils, make_mfa_required_dependency
jwt = JWTUtils("replace-with-a-secret-of-32-bytes-or-more")
require_mfa = make_mfa_required_dependency(jwt)
router = APIRouter(prefix="/staff", tags=["staff"])
@router.post("/invite")
async def invite_staff(claims: dict[str, Any] = Depends(require_mfa)) -> dict[str, str]:
"""Only a session that passed the second factor invites another staff member."""
return {"invited_by": str(claims["sub"])}
A password-only token gets 403 with code MFA_REQUIRED
(MFARequiredException); no token at all, 401. A token without amr —
issued before this version — also gets 403: the rollout window never counts
an unknown session as MFA, and the user leaves it by redoing the two-step
login.
Does a passkey count as MFA?
Not by default: methods= defaults to {"otp"}. If your
AUTH_WEBAUTHN_USER_VERIFICATION is "required", the passkey already
proves possession and a local factor, and you can accept it with
make_mfa_required_dependency(jwt, methods={"otp", "hwk"}).
Confirming MFA ends the other sessions¶
Whoever turns MFA on usually does it because they suspect someone has the
password. So mfa_confirm revokes every refresh token of the user in the
same transaction: a session opened with the password alone, before the factor
existed, gets 401 at its next /auth/refresh. The device that confirmed
loses nothing — the /auth/mfa/confirm route answers 200 with a new pair,
already carrying the MFA amr (in the body for a bearer caller, as cookies
for a caller authenticated by the access cookie).
Calling the service directly, the opt-out is explicit:
mfa_confirm(session, user=user, code=code, revoke_sessions=False). The
session it keeps stays ["pwd"], never "otp". An access token already
issued lives until its short TTL ends, but it is ["pwd"] too, so the
dependency above refuses it.
Settings¶
Mix AuthSettings into your Settings class (as in the auth flow recipe) and configure via env:
# .env — MFA
AUTH_MFA_ENABLED=true # global kill-switch (default false)
AUTH_MFA_ISSUER=Acme Inc. # name shown in the Authenticator
AUTH_MFA_RECOVERY_CODES_COUNT=10 # codes generated at enroll (2..50)
AUTH_MFA_TOKEN_TTL_SECONDS=300 # mfa_token TTL between step 1 and 2 (30..900)
AUTH_MFA_VERIFY_WINDOW=1 # drift tolerance, in 30s steps (0..4)
| Setting | Default | What it does |
|---|---|---|
AUTH_MFA_ENABLED |
False |
Mounts the /auth/mfa/* endpoints and the two-step login. |
AUTH_MFA_ISSUER |
"Tempest" |
Label next to the email in the Authenticator app. Use your product name. |
AUTH_MFA_RECOVERY_CODES_COUNT |
10 |
Number of recovery codes generated at enrollment. |
AUTH_MFA_TOKEN_TTL_SECONDS |
300 |
Lifetime of the intermediate mfa_token (5 min). |
AUTH_MFA_VERIFY_WINDOW |
1 |
Tolerance for the user's clock. 1 accepts previous + current + next step (90s). 0 is strict; above 2 weakens it. |
Attempt limit¶
Since 0.306.0 step 2 of the login has two brakes you do not have to write.
Five wrong codes, and the account waits¶
POST /auth/mfa/verify counts wrong codes per account, not per mfa_token. After five failures within fifteen minutes, the sixth attempt answers 429 without looking at the code, even when it is right:
{"detail":"too many invalid MFA codes, try again later","code":"MFA_THROTTLED","details":{"retry_after_seconds":900}}
The response carries a Retry-After header with the seconds left in the window. That is the body with register_exception_handlers mounted.
The key is the account because the mfa_token holds nothing back: whoever has the password asks POST /auth/login for another one at will. A fresh token keeps getting 429 until the window passes. A right code before the limit logs in normally and clears the count, so someone who mistypes a digit twice owes nothing.
More than one worker? Pass Redis
The default limit keeps the counter in an InMemoryThrottleBackend, which lives in the process. With uvicorn --workers 4, gunicorn or more than one pod, each process has its own counter, and the account reaches 5 * processes attempts per window. In that case, pass an AttemptThrottle over the Redis your processes share:
from fastapi import FastAPI
from redis.asyncio import Redis
from tempest_fastapi_sdk import AttemptThrottle, UserAuthService, make_auth_router
from src.api.dependencies import get_session
from src.db.models import UserRecoveryCodeModel
def mount_auth(app: FastAPI, auth_service: UserAuthService, redis: Redis) -> None:
"""Mount the auth router with the MFA limit on Redis."""
mfa_throttle = AttemptThrottle(redis, max_attempts=5, window_seconds=900)
app.include_router(
make_auth_router(
auth_service,
session_factory=get_session,
recovery_code_model=UserRecoveryCodeModel,
mfa_throttle=mfa_throttle,
),
)
The same parameter changes the budget (max_attempts, window_seconds).
One code, one login¶
A TOTP code stays valid for the whole drift window (AUTH_MFA_VERIFY_WINDOW=1 gives 90 seconds). With nothing else, anyone who saw the code go by (over a shoulder, in a log, at a proxy) could log in with it again inside those 90 seconds.
The SDK now stores the 30-second step of the last accepted code in the totp_last_step column and refuses any code from the same step or an earlier one as if it were a wrong code (at verify, the same 401). It holds for confirm, verify, disable and email recovery (request_email_recovery): the code used to activate MFA no longer works for the first login, and the user waits for the next one to show in the app. The admin panel's challenge (POST /admin/mfa) follows the same rule and has the same attempt limit: see Admin.
The write is a conditional UPDATE: the stored row decides, not the loaded object, so a second session that read the user before the first acceptance is refused too (measured with SQLite, two sessions open over the same row).
New column: totp_last_step
Projects already on MFAMixin need the migration before running 0.306.0 — see the migration guide.
Service direct¶
If you mount your own endpoints (no make_auth_router), the UserAuthService methods cover the whole cycle:
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import UserAuthService
from src.db.models import UserModel, UserRecoveryCodeModel
async def enroll_user(service: UserAuthService, session: AsyncSession, user: UserModel) -> None:
"""Generate secret + recovery codes and show them to the user (once)."""
secret, provisioning_uri, recovery_codes = await service.mfa_enroll(
session,
user=user,
recovery_code_model=UserRecoveryCodeModel,
)
await session.commit()
# render provisioning_uri as a QR; show recovery_codes
async def confirm_user(
service: UserAuthService, session: AsyncSession, user: UserModel, code: str
) -> None:
"""Activate MFA after the user proves they scanned the QR."""
await service.mfa_confirm(session, user=user, code=code)
await session.commit()
Full surface:
| Method | Signature (abridged) | Returns |
|---|---|---|
is_mfa_enrolled |
(user) -> bool |
True if MFA active (and kill-switch on). |
issue_mfa_token |
(user) -> str |
Short JWT bridging step 1 and step 2. |
mfa_enroll |
(session, *, user, recovery_code_model) -> tuple[str, str, list[str]] |
(secret, provisioning_uri, recovery_codes). Raises MFAAlreadyEnrolledException while MFA is active. |
mfa_confirm |
(session, *, user, code) -> None |
Activates MFA. |
mfa_verify |
(session, *, mfa_token, code, recovery_code_model, throttle=None) -> UserModel |
Authenticated user (mint the JWT next). |
mfa_disable |
(session, *, user, password, code, recovery_code_model) -> None |
Clears secret + codes. |
mfa_reset |
(session, *, user, recovery_code_model, revoke_sessions=True) -> int |
Clears secret + codes without proof and revokes the sessions; returns how many fell. An operator path — see An account that lost its second factor. |
change_password_with_mfa |
(session, *, user, code, new_password, recovery_code_model=None, revoke_sessions=True, throttle=None) -> UserModel |
Replaces the password on proof of the factor. Raises MFANotEnrolledException on an account without MFA. |
Changing the password with the factor as proof¶
A staff account forgot its password, and the e-mailed reset link will not do — the inbox is shared, or the e-mail is exactly what is not trusted. If the person still has the Authenticator app (or a recovery code), the factor is the proof: change_password_with_mfa replaces the password without the current one and without a link.
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import UserAuthService
from src.db.models import UserModel, UserRecoveryCodeModel
async def reset_staff_password(
service: UserAuthService,
session: AsyncSession,
user: UserModel,
code: str,
new_password: str,
) -> None:
"""Replace the password of an account with MFA active, proving the factor."""
await service.change_password_with_mfa(
session,
user=user,
code=code,
new_password=new_password,
recovery_code_model=UserRecoveryCodeModel,
)
await session.commit()
What the method guarantees, in this order:
- An account without active MFA is refused with
MFANotEnrolledException(422,code="MFA_NOT_ENROLLED") — including with theAUTH_MFA_ENABLEDkill-switch off, which makes every account read as "no MFA", exactly as at login. - A password outside the policy is refused before the code is checked (
ValidationException), so a weak password does not burn a recovery code. - The code is checked by the same routine as
mfa_verify: a TOTP code is single-use and a recovery code getsused_at. A wrong code raisesUnauthorizedException("invalid MFA code"). - Sessions are revoked by default, in the same transaction, as in
confirm_password_reset.revoke_sessions=Falseis the explicit opt-out.
It is not a login: no token pair is minted and last_login_at does not change. Without recovery_code_model, only TOTP codes are accepted. Pass throttle= to limit attempts: the key is mfa:{user_id}, the same as mfa_verify, so wrong codes here and at the login's second step spend one budget.
From the terminal
tempest user set-password uses this method when the account has MFA active — see CLI ».
Bootstrapping the first privileged account¶
The route that creates staff can require MFA (make_mfa_required_dependency), but the first privileged account has nobody to invite it: it is born from the terminal. tempest user create --require-mfa makes sure it never exists without a second factor:
tempest user create --email staff@example.com --admin --require-mfa \
--audit-model src.db.models:AuditLogModel --event staff.bootstrapped
Add this account to the authenticator app:
otpauth URI: otpauth://totp/Tempest:staff%40example.com?secret=GGZPWWOVQ6QIXMCMDOARELP3SR7JF424&issuer=Tempest
secret: GGZPWWOVQ6QIXMCMDOARELP3SR7JF424
Code from the authenticator app: 123456
Created admin: staff@example.com (id=2ced9167-4e7a-4dc5-8626-72b64ff7e4a7)
MFA active. Recovery codes (shown once, store them now):
_MqMXAW-jzw
...
Piece by piece:
- The user is inserted,
mfa_enrollissues the secret and recovery codes, andmfa_confirmchecks the code — all in one transaction, committed only with a valid code. Three wrong codes: it exits with code 1 and nothing is written. --audit-modelwritesrecord_event(actor="cli",changeswithemail,is_adminandmfa_enrolled) in that same transaction. The model needsAuditRequestMixin— theeventcolumn holds the name. Without--audit-model, the command goes on and says on stderr that no event was recorded: a project without an audit table can still bootstrap.- The recovery-code table comes from
--recovery-code-model(defaultsrc.db.models:UserRecoveryCodeModel), and theUserModelneedsMFAMixin; with either missing, the command exits with code 2 before writing.
Afterwards, tempest user set-password --email staff@example.com on that account asks for the code (TOTP or recovery code) and changes the password through change_password_with_mfa. With AUTH_MFA_ENABLED off, neither login nor set-password asks for the factor.
An account that lost its second factor¶
The account holder lost the phone and the recovery codes. disable asks for the password and a code; the CLI's set-password asks for the code. Neither helps — and that is how it should be, because asking for the factor is the point. The way back belongs to the operator, explicit and audited:
Remove the second factor of staff@example.com? It will log in with the password alone until it enrolls again. [y/N]: y
MFA reset for staff@example.com (id=2ced9167-4e7a-4dc5-8626-72b64ff7e4a7)
Revoked 2 active refresh token(s).
Piece by piece:
- Confirmation is required. In a terminal, the command asks; outside one (CI, a pipe), it needs
--yesand exits with code 1 without it. Declining the prompt changes nothing. - The reset is
UserAuthService.mfa_reset: it clearstotp_secret,totp_enabled_atandtotp_last_stepand deletes every recovery code of the user — the same cleanup asmfa_disable, without the proof. - Sessions are revoked by default, in the same transaction: whoever kept the lost phone may hold a live session too.
--keep-sessionsis the explicit opt-out; the table comes from--refresh-token-model(defaultsrc.db.models:UserRefreshTokenModel). --audit-modelwritesrecord_eventin the same transaction (actor="cli",changeswithmfa_enabledandsessions_revoked), named by--event(defaultuser.mfa_reset). If the audit write fails, the reset rolls back with it. Without--audit-model, the command goes on and says on stderr that no event was recorded.- An account without MFA active exits with code 2 and
has no MFA active, nothing to reset.— nothing is written, not even the event.
After the reset the account logs in with the password alone until it enrolls again — and set-password stops asking for a code. If the password is lost too, run both, in that order.
From code, without the CLI — in a support panel, say, behind your own permission check:
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import UserAuthService
from src.db.models import UserModel, UserRecoveryCodeModel
async def support_mfa_reset(
service: UserAuthService,
session: AsyncSession,
user: UserModel,
) -> int:
"""Remove the second factor of an account that lost it. Returns revoked sessions."""
revoked = await service.mfa_reset(
session,
user=user,
recovery_code_model=UserRecoveryCodeModel,
)
await session.commit()
return revoked
mfa_reset proves nothing
It checks neither password nor code: it is an operator path. Never expose it on a route the account holder reaches — there it becomes the shortcut disable exists to prevent. Record the audit event in the same transaction.
Accounts that do not use emailed links (v0.316.0+)¶
An operator, a staff account, a service account: whoever reads the inbox should not be able, on their own, to change the password or activate the account. With MFA enrolled the link no longer logs in (it answers the challenge), but it still changes the password — and whoever controls the inbox locks the owner out. The SDK accepts a per-account predicate that closes both link flows:
# src/api/app.py
from tempest_fastapi_sdk import UserAuthService
from src.core.settings import settings
from src.db.models import UserModel, UserTokenModel
def refuses_email_link(user: UserModel) -> bool:
"""Operators never use emailed links."""
return user.is_admin
auth_service = UserAuthService(
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=settings,
jwt_settings=settings,
refuses_email_link=refuses_email_link,
)
The predicate receives an instance of your UserModel (so it can be
typed against it) and returns a bool — True refuses. The
refuses_email_link=None default keeps the previous behavior.
When the predicate matches:
POST /auth/password-reset/requestandPOST /auth/activation/requestanswer the same202with the same body as an unknown address, and persist no token and send no email. The refusal cannot be enumerated.POST /auth/password-reset/confirmandPOST /auth/activate/{token}answer403with the codeEMAIL_LINK_FORBIDDEN. The token is consumed and the spend is committed before the error (the link never works again), and the password andis_activestay intact.-
The HTML pages of
AUTH_BACKEND_LINKS=true—GET /auth/activate/{token},GETandPOST /auth/password-reset/{token}— render the flow's error page with status403. The reset formGETdoes not even show the form (and, since it only peeks the token, does not consume it). -
POST /auth/signupwithAUTH_AUTO_ACTIVATE=falsecreates the account inactive and persists no activation token and sends no email. The response is the same201as an ordinary pending signup (activation_required=true,activation_url=null, no JWT pair): only theuser_iddiffers. Activate that account out of band —tempest user activate, or create it already active withtempest user create.
The predicate sees the freshly inserted row, with the signup_schema
columns already set and before on_signup runs: what it reads has to
come in the signup body, not be written by the hook.
With AUTH_AUTO_ACTIVATE=true signup does not consult the predicate
The account is born active and depends on no link, so signup returns
the JWT pair as always. The reset flows stay closed for it.
In dev mode, the body tells them apart
With AUTH_RETURN_TOKEN_IN_RESPONSE=true (or no EmailUtils) an
ordinary signup returns the activation_url in the body and a refused
one returns null — the same thing POST /auth/activation/request
already does in that mode. The identical response holds when the link
goes out by email.
Callers of UserAuthService.signup get, in the second tuple item, None
(the account was born active), an ActivationToken (link issued) or an
ActivationWithheld (link withheld). Only None authorizes minting JWTs.
Swap the code
Like account_inactive, email_link_forbidden is a field of
AuthExceptions: pass a subclass with your own code so the consumer
keeps reacting to what it already knows. The body translation
(EMAIL_LINK_FORBIDDEN, PT-BR and EN in the default catalog) still
applies.
Security¶
- TOTP secret persisted on the
UserModel. Consider encrypting thetotp_secretcolumn at rest (Postgrespgcryptoor an application-level Fernet wrapper). - Recovery codes stored as SHA-256 hashes. The plaintext leaves only once at enrollment; a table leak yields no usable codes.
- Recovery codes are single-use.
used_atis stamped on consume; replay is rejected. disablerequires password + code, andenrollis not a shortcut around it. A hijacked session cannot disable MFA on its own — it needs the password and an active factor.enrollover active MFA answers409instead of wiping the recovery codes, so the access token alone cannot reset the factor.mfa_tokenis short and user-bound. 5-min TTL by default; carriespurpose: "mfa_pending"+ thesub. Tokens of any other purpose are rejected inmfa_verify.- Constant-time verification.
TOTPHelper.matching_step(andverify, which calls it) compares the code of each step in the window withhmac.compare_digest. - Single-use TOTP codes and a per-account limit. See Attempt limit.
Recap¶
- TOTP is the 6-digit code in an Authenticator app: the server and the app share a secret, and the code is derived from it plus the clock.
- The extra is
[mfa](it bringspyotp) on top of[auth], and nothing is mounted unlessAUTH_MFA_ENABLED=True. - The router requires
recovery_code_model: without it, it raisesRuntimeErrorat wiring time rather than in production — losing the phone with no recovery code means losing the account. - With MFA on,
POST /auth/loginstops returning the JWT pair: it returnsmfa_required=Trueplus an intermediate token, and the second step trades a code for the JWTs. - If you mount your own endpoints, the
UserAuthServicemethods cover the whole cycle without the router.change_password_with_mfareplaces the password on proof of the factor, with no e-mail link and no token minted. - Whoever lost the phone and the recovery codes comes back through the
operator:
tempest user mfa-reset(ormfa_resetin code) removes the factor without proof, with confirmation, an audit event in the same transaction and the sessions revoked. - The TOTP secret lives on
UserModel— consider encrypting that column at rest. /auth/mfa/verifyanswers429after five wrong codes per account in 15 minutes, and each TOTP code is worth one login. With more than one worker, passmfa_throttle=over Redis.- The access token says how the session started (
amr), andmake_mfa_required_dependencyrefuses with403MFA_REQUIREDthe one that never passed the second factor. Confirming MFA ends the other sessions. refuses_email_link=closes the emailed-link flow for accounts such as operators: the request answers like an unknown address, confirmation returns403EMAIL_LINK_FORBIDDENwithout changing the password, and signup creates the account inactive without sending a link.
Next steps¶
- Auth flow (signup/reset) » — the local-account flow MFA extends.
- Server-side sessions » — JWT alternative, combinable with MFA at step 1.
- Security » — CSRF, rate-limit and body-size limit for the auth endpoints.