> For the complete documentation index, see [llms.txt](https://docs.layeronecloud.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.layeronecloud.com/platform/identity/two-factor.md).

# Two-factor: TOTP and passkeys

Opt-in per account, never enforced platform-wide.

## 1.8 Two-factor: TOTP <a href="#id-18-two-factor-totp" id="id-18-two-factor-totp"></a>

Opt-in per account, never enforced platform-wide. TOTP (RFC 6238), one enrolled authenticator, ten single-use recovery codes. `apps/accounts/totp.py` implements the algorithm on the standard library and is held to the RFC 4226/6238 test vectors — a one-off truncation bug produces codes that are perfectly self-consistent, so an implementation checked only against itself passes everything and matches no authenticator app in the world. `segno` draws the QR.

Five decisions carry weight:

1. **A device row is not an enrolment.** `begin_enrollment` writes a row with a live secret *before* anything is proven, because the secret has to survive the trip to the authenticator app and a page reload must not mint a second one mid-scan. `confirmed_at` is the switch and `mfa_is_enabled` asks for that, never `MfaDevice.exists()`. Treating the row as enrolment locks out anyone who opened the setup page and walked away, discovered at their next sign-in.
2. **The replay counter must be persisted.** A code stays arithmetically valid for its step plus one drift step either side. `verify_code` returns **the counter it matched**, not a boolean, and the caller must persist it to `last_used_counter`; a caller that treats the return value as merely truthy silently makes a shoulder-surfed code replayable for 90 seconds, with nothing about the flow looking different.
3. **The challenge needs its own throttle, and hitting it must clear the pending sign-in.** The password throttle is already satisfied by the time a challenge is issued. `MFA_CHALLENGE_MAX_ATTEMPTS` (5 per account per 15 min) is that limit; a challenge the guesser can sit on until it expires is not a limit. The half-signed-in state is `MFA_PENDING_SESSION_KEY` on the still-anonymous session — never a signed URL parameter or hidden field, which travel through the browser — re-validated against the database on every read, and cleared **after** `_complete_login` (`login()` cycles the session key but keeps its data, so clearing first copies the payload back in).
4. **Recovery codes exist before the gate closes and are shown exactly once.** Issued by `confirm_enrollment` itself; stored as `salted_hmac` digests, so they cannot be reprinted and reissuing replaces the whole set (a partial reissue leaves a printout where some entries work and some do not). `consume_recovery_code` puts the `used_at__isnull=True` filter **inside** the UPDATE so two concurrent submissions cannot both see it unused, and consumption is a timestamp rather than a delete so "3 of 10 left" stays answerable. `account_mfa_confirm` and `account_mfa_recovery_codes` **render** instead of redirecting — the exception to the POST-then-redirect rule, because the codes exist in clear only in the return value of the call that made them.
5. **Turning it off needs the password, and support can reset it.** `MfaPasswordConfirmForm` re-asks for the password, which is the thing a cookie thief does not have. It deliberately does *not* also demand a current authenticator code: someone whose phone is gone signs in with a recovery code and must be able to re-enrol. `admin_user_reset_mfa` is the last resort, is `super_admin_required`, stays off `SUPPORT_CONSOLE_ROUTES`, and has to exist — password reset proves control of the email address and says nothing about the second factor, so without it a lost phone plus lost codes is a permanent lockout. Audited as `MFA_RESET_BY_ADMIN` with both actor and account.

Every rejection is generic: "that code is not valid" covers a wrong TOTP, a replayed TOTP, a spent recovery code and an invented one. `MfaChallengeForm` validates only "not blank", so the form does not tell a submitter their code was the wrong *shape*. `MFA_CHALLENGE_FAILED` is recorded with `success=False` on every miss — a burst on one account is the signal that a password is already known.

Disabling **deletes** the device: a disabled row holding a live secret is a credential nobody watches.

The QR travels as an SVG **data URI**, so the template renders it through a normal escaped `src` and there is no image endpoint carrying the shared secret in a query string into access logs. `.ui-setup__qr` pins `background: #ffffff` and black-on-white modules and is **deliberately not themed**: surface tokens invert it in dark mode and the symptom reaches the user as "my camera is broken".

## 1.9 Two-factor: passkeys <a href="#id-19-two-factor-passkeys" id="id-19-two-factor-passkeys"></a>

**Passkeys are a second factor after a password, never a password replacement.** Sign-in is always email + password first. If the account has a passkey, `/account/two-factor/` leads with a WebAuthn prompt and still offers TOTP or recovery codes when configured; cancelling or failing the passkey does not throw the pending sign-in away. There is no `/sign-in/passkey/` path.

* A passkey never identifies the account: `authentication_options` requires a `user` and `complete_assertion` requires `expected_user`, so `allowCredentials` is always scoped to the pending user.
* "Use another method" cannot be a link away, because GET `/account/sign-in/` clears the pending state. The challenge page keeps the TOTP/recovery form in the DOM and switches to it in the browser.
* **The first confirmed factor issues recovery codes.** They live on the *user* (`MfaRecoveryCode.user`), not on `MfaDevice`, so a passkey-only account still has a way back in. Removing the last factor deletes them with it.
* `mfa_is_enabled` is confirmed TOTP **or** any `Passkey` row (`exists()` is the switch there, because a passkey row is only written after the ceremony verified). The setup page starts TOTP from `totp_is_enabled`, or a passkey-only user could never add an authenticator app.
* Adding or removing a passkey **re-asks the password**. `admin_user_reset_mfa` deletes passkeys too.
* `WEBAUTHN_RP_ID` / `WEBAUTHN_ORIGIN` default to the current request (right for `localhost`/`testserver`); production must set them to the public site so a Railway hostname cannot mint passkeys that only work on that host.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.layeronecloud.com/platform/identity/two-factor.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
