Backend guides
Two-factor authentication
How time-based one-time password (TOTP) two-factor authentication works, and how to enroll, verify and recover.
On this page
Slicekit ships optional two-factor authentication using TOTP (Time-based One-Time Password, RFC 6238): the six-digit codes that authenticator apps like 1Password, Google Authenticator or Aegis generate. It builds directly on ASP.NET Core Identity, which owns the secret, the recovery codes and the code verification. The feature layers a login challenge and enrollment flow on top. For the password and session machinery underneath, see Authentication.
The feature gate
The whole feature is gated by Auth:TotpEnabled (default true). When it is off, the TOTP endpoints
are never mapped and the frontend hides the 2FA section entirely.
// appsettings.json
"Auth": {
"TotpEnabled": true,
"TotpRequired": false
}
Override at runtime with Auth__TotpEnabled=false. In RegisterEndpoints.cs, the registration is
conditional:
if (auth.TotpEnabled) v1.MapV1TotpEndpoints();
GET /api/v1/auth/features reflects the same flags, so the SPA reads totpEnabled from its features
hook and only renders the setup card when the feature is on. Setting TotpRequired: true additionally
forces enrollment: an endpoint filter (TotpSetupRequiredEndpointFilter) blocks normal endpoints for
users who have not set up 2FA, except the few setup routes that opt out with AllowWithoutTotpSetup().
Five slices, five endpoints
Each operation is a vertical slice in Slicekit.Core/Features/Auth/ with a thin endpoint in
Slicekit.Api/Endpoints/v1/. See Adding a vertical slice for the pattern.
| Method | Route | Auth | Slice |
|---|---|---|---|
POST | /api/v1/me/totp/setup | Bearer | SetupTotp |
POST | /api/v1/me/totp/confirm | Bearer | ConfirmTotp |
DELETE | /api/v1/me/totp | Bearer | DisableTotp |
POST | /api/v1/auth/totp/verify | Anonymous | VerifyTotp |
POST | /api/v1/auth/totp/recover | Anonymous | RedeemTotpRecoveryCode |
Enrollment
Enrollment is two calls. The user is already signed in, so both setup routes require a bearer session
(Allow.UserGetMe), a confirmed email, and CSRF.
Step 1: begin setup. POST /api/v1/me/totp/setup resets the user’s authenticator key in Identity
and returns the raw secret plus an otpauth:// URI that the SPA renders as a QR code.
// SetupTotp/Handler.cs
await userManager.ResetAuthenticatorKeyAsync(user);
var key = await userManager.GetAuthenticatorKeyAsync(user);
var qr = $"otpauth://totp/{Uri.EscapeDataString(Issuer)}:{Uri.EscapeDataString(user.Email!)}"
+ $"?secret={key}&issuer={Uri.EscapeDataString(Issuer)}&digits=6&period=30";
return new SetupTotpResult(key, qr);
// Response
{ "secretBase32": "JBSWY3DPEHPK3PXP", "qrCodeUri": "otpauth://totp/Slicekit:[email protected]?secret=..." }
Setting TwoFactorEnabled does not happen here. The key is provisioned but inert until the user proves
they can read codes from it.
Step 2: confirm. The user scans the QR code, then sends back the first six-digit code. POST /api/v1/me/totp/confirm verifies it, flips TwoFactorEnabled on, generates ten one-time recovery
codes, and raises TotpEnabledEvent.
// ConfirmTotp/Handler.cs
var ok = await userManager.VerifyTwoFactorTokenAsync(
user, TokenOptions.DefaultAuthenticatorProvider, command.Code);
if (!ok) return (new TotpInvalidError(), messages);
await userManager.SetTwoFactorEnabledAsync(user, true);
var codes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
messages.Add(new TotpEnabledEvent(user.Id));
// Request // Response
{ "code": "123456" } { "recoveryCodes": ["a1b2-c3d4", "..."] }
The recovery codes are returned exactly once. Identity stores only their hashes, so the API can never show them again. The SPA displays them in a one-time dialog and tells the user to save them.
Login challenge
When a user with 2FA enabled signs in, the login handler detects TwoFactorEnabled and refuses to
issue a session. Instead the login endpoint returns a TotpRequiredError carrying a short-lived pending
token (a JWT, not a session cookie):
// LoginEndpoint.cs
if (result.Value.TotpRequired)
{
var pendingToken = tokenService.GenerateTotpPendingToken(result.Value.UserId);
return new TotpRequiredError(
pendingToken, TotpAvailable: true,
PasskeyAvailable: result.Value.PasskeyAvailable).ToProblem();
}
The pending token only unlocks the verify and recover endpoints. It cannot authenticate any normal endpoint, so possession of it alone gets an attacker nowhere.
The client completes login by posting that token plus a code. POST /api/v1/auth/totp/verify validates
the token, verifies the code via Identity, and only then issues the real session:
// TotpVerifyEndpoint.cs
var pending = await tokenService.ValidateTotpPendingTokenAsync(request.PendingToken);
if (pending is null) return new UnauthorizedError().ToProblem();
var result = await bus.InvokeAsync<Result<VerifyTotpResult>>(
new VerifyTotpCommand(pending.UserId, request.Code), ct);
if (!result.IsSuccess) return result.Error.ToProblem();
await sessionIssuer.IssueAsync(httpContext, response, result.Value.UserId, result.Value.Permissions, ct);
await tokenService.MarkTotpPendingTokenUsedAsync(pending.TokenId);
return TypedResults.NoContent();
// Request
{ "pendingToken": "eyJ...", "code": "123456" }
// 204 No Content, session cookies set
A successful verify resets the access-failed count, records the login on the domain user, and issues
the session. The response is a bare 204 with the session cookies attached.
Recovery
If the user loses their authenticator, they redeem one of the codes from enrollment instead. POST /api/v1/auth/totp/recover takes the same pending token plus a recovery code:
// RedeemTotpRecoveryCode/Handler.cs
var result = await userManager.RedeemTwoFactorRecoveryCodeAsync(appUser, command.Code);
if (!result.Succeeded) { /* throttle, then */ return (new TotpInvalidError(), messages); }
// Request
{ "pendingToken": "eyJ...", "recoveryCode": "a1b2-c3d4" }
// 204 No Content, session cookies set
Identity consumes the redeemed code, so each one works exactly once. Recovery codes are not auto regenerated. A user who burns through them should disable and re-enroll to get a fresh set.
Disabling
DELETE /api/v1/me/totp turns 2FA off. It requires the account password in the body, even though the
caller is already authenticated, so a hijacked session cannot quietly strip protection:
// DisableTotp/Handler.cs
if (user.PasswordHash is null || !await userManager.CheckPasswordAsync(user, command.Password))
return (Errors.InvalidCredentials, messages);
await userManager.SetTwoFactorEnabledAsync(user, false);
await userManager.ResetAuthenticatorKeyAsync(user);
messages.Add(new TotpDisabledEvent(user.Id));
Disabling clears the authenticator key as well, so re-enrolling later starts from a brand new secret.
Throttling
The verify and recover endpoints sit behind the shared auth rate-limit policy (the same bucket as
login). On top of that, both handlers keep a per-account attempt counter in the hybrid cache so a
single account cannot be ground down without tripping a per-account lock:
Auth:MaxTotpAttemptsPerAccountWindow(default5) caps verify attempts.Auth:MaxRecoveryCodeAttemptsPerAccountWindow(default5) caps recovery redemptions.
Hitting either limit returns a LockedError until the window (Auth:LockoutMinutes) elapses; a
successful attempt clears the counter. For the broader policy, see Rate limiting.
Checklist
-
Auth:TotpEnabledistrue(the default); setTotpRequiredif enrollment must be mandatory. - Enroll with
POST /me/totp/setup, render theqrCodeUri, thenPOST /me/totp/confirmwith the first code. - Capture the ten recovery codes from the confirm response; they are shown once.
- At login, handle
TotpRequiredError, collect a code, andPOST /auth/totp/verifywith the pending token. - Offer
POST /auth/totp/recoveras the lost-device path. - Require the password on
DELETE /me/totpto disable.