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 "two_factor_required" - Wrong code → 401 "two_factor_invalid" ↓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: ...");}The error prefixes (two_factor_required / two_factor_invalid) are stable strings the React clients key off to show the code-entry step. 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.
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.