Two-factor TOTP is opt-in per user in fullstackhero. Once enrolled, the user must provide a six-digit TOTP code from their authenticator app (Google Authenticator, Authy, 1Password, Bitwarden, etc.) on every login. Three endpoints handle the lifecycle: enrol (returns the shared secret + otpauth URI), verify (activates the enrolment), disable (turns it off, after confirming the password).
The flow
1. User: POST /api/v1/identity/2fa/enroll ↓2. Identity module: - Generates (or rotates) the user's authenticator shared secret - Builds the otpauth:// URI (issuer from TwoFactorOptions:Issuer) - Returns { sharedKey, authenticatorUri } ↓3. Client renders authenticatorUri as a QR code; user scans it, app starts generating 6-digit codes ↓4. User: POST /api/v1/identity/2fa/verify { code } - If the code matches the current TOTP window, 2FA is activated - User.TwoFactorEnabled = true persists ↓5. From now on, POST /token/issue requires { email, password, twoFactorCode } (the third field is required iff enrolled) - Missing code → 401, ProblemDetails code "Identity.TwoFactorRequired" - Wrong code → 401, ProblemDetails code "Identity.TwoFactorInvalid" ↓6. To turn off: POST /api/v1/identity/2fa/disable { currentPassword } - Requires the current password, so a stolen access token alone can't downgrade account security; also rotates the secretEndpoints
All under /api/v1/identity, all requiring an authenticated caller - TOTP is a per-user setting.
| Verb | Route | What |
|---|---|---|
| POST | /2fa/enroll | Generate (or rotate) the secret; return sharedKey + authenticatorUri for the client to render as a QR |
| POST | /2fa/verify | Verify the first TOTP code; activate 2FA |
| POST | /2fa/disable | Disable 2FA after confirming the current password; rotates the secret |
Calling enroll again before verifying rotates the secret - a stale code from a prior incomplete enrolment can’t silently succeed. The verify endpoint strips spaces from the code, so “123 456” works.
What the kit does NOT ship
- SMS / email 2FA. Only TOTP. SMS is widely-acknowledged-insecure (SIM-swap attacks); email mostly defeats the second-factor purpose since it shares the same compromised channel as password recovery.
- Backup / recovery codes. The kit doesn’t generate one-time backup codes. If a user loses their authenticator device, support has to reset 2FA manually (disable, then re-enrol). For production with non-technical users, this is the biggest gap to fill - implement 8-10 single-use recovery codes generated at enrolment time, hashed-and-stored, displayed to the user once.
- WebAuthn / passkeys. No passkey support in v10. Roadmap.
TOTP integration
The kit uses ASP.NET Identity’s built-in authenticator support - the shared secret is managed by UserManager token providers; you never write to it directly.
// EnrollTwoFactorCommandHandler (simplified)public async ValueTask<TwoFactorEnrollmentResponse> Handle(EnrollTwoFactorCommand command, CancellationToken ct){ var user = await _userManager.FindByIdAsync(_currentUser.GetUserId().ToString()) ?? throw new NotFoundException("user not found");
// Always reset so calling enroll twice rotates the secret await _userManager.ResetAuthenticatorKeyAsync(user); var sharedKey = await _userManager.GetAuthenticatorKeyAsync(user);
// _options is IOptions<TwoFactorOptions>.Value; the issuer is percent-encoded in both places var issuer = UrlEncoder.Default.Encode(_options.Issuer); var authenticatorUri = $"otpauth://totp/{issuer}:{UrlEncoder.Default.Encode(user.Email)}" + $"?secret={sharedKey}&issuer={issuer}&digits=6";
return new TwoFactorEnrollmentResponse(sharedKey, authenticatorUri);}The otpauth:// URI is the RFC 6238 standard format that every authenticator app understands. The client (not the server) renders it as a QR code; the raw sharedKey is returned too, for users who can’t scan.
Issuer name
The issuer is the name the authenticator app shows next to the account. It comes from TwoFactorOptions:Issuer and defaults to FullStackHero (a project created with fsh new or dotnet new fsh gets its own display name instead), so set it to your product name:
{ "TwoFactorOptions": { "Issuer": "Acme" }}Or through the environment: TwoFactorOptions__Issuer=Acme. The value is percent-encoded in both the label and the issuer= parameter, so names with spaces or accents (Canal Correto, Ação) produce a valid URI. A blank issuer fails options validation at startup. Changing it only affects new enrolments: users who enrolled earlier keep the old name in their app, and their codes keep working because the shared secret does not change. They see the new name after they disable and enrol again.
Validating during login
During token/issue, after the password check passes:
// IdentityService (simplified)if (user.TwoFactorEnabled){ if (string.IsNullOrWhiteSpace(twoFactorCode)) throw new CustomException("two_factor_required: ...", null, HttpStatusCode.Unauthorized);
var valid = await _userManager.VerifyTwoFactorTokenAsync( user, _userManager.Options.Tokens.AuthenticatorTokenProvider, twoFactorCode);
if (!valid) throw new UnauthorizedException("two_factor_invalid: ...");}Both exceptions carry a MessageKey, which the global exception handler writes to the ProblemDetails body as a machine-readable code next to the localized detail:
{ "title": "Unauthorized", "status": 401, "detail": "two_factor_required: An authenticator code is required to complete sign-in.", "code": "Identity.TwoFactorRequired"}Branch on code (Identity.TwoFactorRequired / Identity.TwoFactorInvalid), not on detail: the detail text follows the request culture and changes with Accept-Language. The whole login - including 2FA attempts - sits behind the rate-limit auth policy, which is the throttle on TOTP brute-forcing. Failed TOTP codes do not increment the password-lockout counter; only failed passwords do.
Signing in from the React apps
The admin and dashboard login pages handle the second factor as a two-step card:
- The user submits tenant, email and password. For an account without 2FA,
token/issuereturns tokens and the user is in, in one step. - If the API answers
401withcode: "Identity.TwoFactorRequired", the card switches to a code step: a six-digit field withinputmode="numeric"andautocomplete="one-time-code"(so the browser or a password manager can fill it), focused on arrival. - On submit, the page repeats
token/issuewith{ email, password, twoFactorCode }and the sametenantheader. - A wrong or expired code (
code: "Identity.TwoFactorInvalid") shows an inline error, clears the field and keeps the user on the step. “Back to sign in” returns to the password step.
The password is kept only in the login page’s component state for that retry. It never goes to localStorage or sessionStorage, the password field is cleared when the code step opens, and the held credentials are dropped on back, on success and when the page unmounts. The detection lives in isTwoFactorRequiredError / isTwoFactorInvalidError in each app’s src/auth/api.ts, and the step itself is src/components/auth/two-factor-step.tsx.
The code step accepts authenticator codes only. Recovery codes are not part of the kit (see below), so there is no “use a recovery code” link.
Operational considerations
-
Time skew. TOTP codes are valid for a small window (typically 30 seconds + 1 step on either side). Servers and users’ devices must have accurate clocks. NTP everywhere.
-
Device loss. Without backup codes, a lost device means admin intervention. Build a tested support runbook before turning 2FA on for non-technical users.
-
Recovery codes (recommended add-on). Add backup-code generation to the enrolment flow and store them hashed. Pattern:
1. At enrol: generate 10 random alphanumeric codes2. Hash them via Identity password-hasher3. Display the plaintext codes ONCE to the user4. On login, accept a code in place of the TOTP code; mark the used codeThis isn’t shipping in v10 but it’s a ~200 LoC addition you’d add in your fork.
-
Watch disable events. A spike of 2FA disables on one account is a red flag (account takeover trying to remove the second factor). The disable endpoint requires the password precisely to raise that bar - alert on it anyway.
Related
- Authentication - the login flow 2FA stacks onto.
- Identity module - full endpoint inventory.
- Production checklist - when to make 2FA mandatory.