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 "FullStackHero") - 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);
var authenticatorUri = $"otpauth://totp/FullStackHero:{user.Email}" + $"?secret={sharedKey}&issuer=FullStackHero&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.
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.