Skip to content
Slicekit

Backend guides

Two-factor authentication

How time-based one-time password (TOTP) two-factor authentication works, and how to enroll, verify and recover.

View .md
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.

MethodRouteAuthSlice
POST/api/v1/me/totp/setupBearerSetupTotp
POST/api/v1/me/totp/confirmBearerConfirmTotp
DELETE/api/v1/me/totpBearerDisableTotp
POST/api/v1/auth/totp/verifyAnonymousVerifyTotp
POST/api/v1/auth/totp/recoverAnonymousRedeemTotpRecoveryCode

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 (default 5) caps verify attempts.
  • Auth:MaxRecoveryCodeAttemptsPerAccountWindow (default 5) 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:TotpEnabled is true (the default); set TotpRequired if enrollment must be mandatory.
  • Enroll with POST /me/totp/setup, render the qrCodeUri, then POST /me/totp/confirm with the first code.
  • Capture the ten recovery codes from the confirm response; they are shown once.
  • At login, handle TotpRequiredError, collect a code, and POST /auth/totp/verify with the pending token.
  • Offer POST /auth/totp/recover as the lost-device path.
  • Require the password on DELETE /me/totp to disable.