fullstackhero/dotnet-starter-kit · error · UnauthorizedException
two_factor_invalid: The authenticator code is invalid or…
Error message
two_factor_invalid: The authenticator code is invalid or expired.
What it means
VerifyTwoFactorOrThrowAsync validates the supplied code via UserManager.VerifyTwoFactorTokenAsync with the AuthenticatorTokenProvider; when the code does not match (wrong, stale, already used, or clock drift) it logs a warning for the user and throws UnauthorizedException with the two_factor_invalid marker.
Solutions
- Generate and submit a fresh code immediately; if it fails, wait for the next TOTP window and retry once.
- Check the device/system clock synchronization (NTP) — skewed clocks are the top cause.
- Confirm the authenticator entry matches the account's current secret; re-enroll by re-scanning the QR if the secret was reset.
- Trim spaces from the pasted code and ensure you send a TOTP code, not a recovery code, to this path.
Example fix
// before
await login({ email, password, twoFactorCode: cachedCode }); // stale code -> 401
// after
const code = await promptTotp(); // fresh from authenticator, trimmed
await login({ email, password, twoFactorCode: code.trim() }); Defensive patterns
Strategy: retry
Validate before calling
const code = getFreshTotpCode().trim();
if (!/^\d{6}$/.test(code)) { showError('Enter the 6-digit code'); return; } Type guard
function isValidTotpFormat(code) {
return typeof code === 'string' && /^\d{6}$/.test(code.trim());
} Try / catch
try { await login({ email, password, twoFactorCode: code }); }
catch (e) {
if (String(e.message).includes('two_factor_invalid')) {
await waitForNextTotpWindow();
return retryWithFreshCode(); // one retry max
}
throw e;
} Prevention
- Generate the code right before submitting; don't cache codes.
- Keep device clocks NTP-synced.
- Trim whitespace from pasted codes.
- Re-enroll the authenticator after any QR/secret reset.
When it happens
Trigger: Typing an expired TOTP (code window passed), a code generated against a wrong secret (e.g. re-enrolled authenticator), garbage/placeholder values, or a recovery code sent where a TOTP token is expected.
Common situations: User's device clock skewed by minutes; user re-scanned the QR so old app entries are invalid; copy-paste including whitespace; latency between generating and submitting the last seconds of a 30s window.
Understand the failure class
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
Related errors
- two_factor_required: An authenticator code is required to…
- Unauthorized
- Unauthorized
- Unauthorized
- The authenticator code is invalid.
AI-assisted analysis of fullstackhero/dotnet-starter-kit@3f2959e683 (2026-09-15).
Data as JSON: /api/errors/67024915151fa8e5.
Report an issue: GitHub.
Appendix: source
Thrown at src/Modules/Identity/Modules.Identity/Services/IdentityService.cs:86
private async Task VerifyTwoFactorOrThrowAsync(FshUser user, string? twoFactorCode)
{
if (string.IsNullOrWhiteSpace(twoFactorCode))
{
throw new CustomException(
"two_factor_required: An authenticator code is required to complete sign-in.",
errors: null,
HttpStatusCode.Unauthorized);
}
var valid = await _userManager.VerifyTwoFactorTokenAsync(
user,
_userManager.Options.Tokens.AuthenticatorTokenProvider,
twoFactorCode);
if (!valid)
{
_logger.LogWarning("Invalid two-factor code for user {UserId}", user.Id);
throw new UnauthorizedException("two_factor_invalid: The authenticator code is invalid or expired.");
}
}
public async Task<(string Subject, IEnumerable<Claim> Claims)?>
ValidateRefreshTokenAsync(string refreshToken, CancellationToken ct = default)
{
var tenant = GetValidatedTenant();
var user = await FindUserByRefreshTokenAsync(refreshToken, tenant.Id, ct);
ValidateRefreshTokenExpiry(user);
ValidateUserStatus(user);
ValidateTenantStatus(tenant);
var claims = await BuildUserClaimsAsync(user, tenant.Id, ct);
return (user.Id, claims);
}
public async Task StoreRefreshTokenAsync(string subject, string refreshToken, DateTime expiresAtUtc, CancellationToken ct = default)View on GitHub (pinned to 3f2959e683)