AuthServer
MFA and TOTP
Configure authenticator-app MFA, recovery codes, and tenant enforcement.
SqlOS MFA adds authenticator-app second factor checks to the AuthServer login path. The first supported strong factor is TOTP, the 6-digit code format used by Google Authenticator, Microsoft Authenticator, 1Password, Authy, and other compatible apps.
The default is intentionally conservative:
SqlOS evaluates MFA after the primary authentication step and after an organization context is known.
| Layer | What it controls |
|---|---|
| Code defaults | First persisted defaults for MFA availability, TOTP settings, self-enrollment, recovery codes, and global requirements |
| Startup seed | Reapplied policy values for environments where code/config owns the setting |
| Dashboard global settings | Live admin control for application-wide MFA settings |
| Organization policy | Tenant-specific requirement and enrollment policy through admin API or startup seed |
| User state | Whether the user already has a confirmed authenticator and recovery codes |
If MFA is required and the user already has a confirmed authenticator, SqlOS returns a second-factor challenge. If MFA is required and the user has no confirmed authenticator, SqlOS returns a forced enrollment step before tokens or authorization codes are issued.
Use ConfigureMfa for defaults that create the persisted MFA settings row on first boot:
builder.AddSqlOS<AppDbContext>(options =>
{
options.AuthServer.ConfigureMfa(mfa =>
{
mfa.Enabled = true;
mfa.AllowUserSelfEnrollmentByDefault = true;
mfa.RecoveryCodesEnabledByDefault = true;
mfa.RequireForAllUsersByDefault = false;
mfa.RequireForOwnersAndAdminsByDefault = false;
mfa.Totp.Issuer = "Contoso";
});
});Turn MFA off completely:
builder.AddSqlOS<AppDbContext>(options =>
{
options.AuthServer.ConfigureMfa(mfa => mfa.Disable());
});SqlOS follows the same code-defaults plus dashboard pattern used by AuthPage and email settings. Defaults initialize the row once. A seed reapplies values on each boot.
Use SeedMfaPolicy when app configuration should own the live values:
builder.AddSqlOS<AppDbContext>(options =>
{
options.AuthServer.SeedMfaPolicy(mfa =>
{
mfa.Enabled = true;
mfa.TotpEnabled = true;
mfa.UserSelfEnrollmentEnabled = true;
mfa.RecoveryCodesEnabled = true;
mfa.RequireForAllUsers = false;
mfa.RequireForOwnersAndAdmins = true;
mfa.RequiredRoles = ["owner", "admin"];
mfa.AvailableFactors = ["totp", "recovery_code"];
});
});When a startup seed is active, the dashboard labels the MFA screen as startup managed because edits will be overwritten on restart.
Organization MFA policies inherit global settings until overridden. Use the admin API or startup seed for tenant-specific policy.
curl -X PUT http://localhost:5062/sqlos/admin/auth/api/organizations/org_123/mfa-policy \
-b "$SQLOS_DASHBOARD_COOKIE_JAR" \
-H "Content-Type: application/json" \
-d '{
"isEnabled": true,
"requireMfaForAllUsers": true,
"requireMfaForOwnersAndAdmins": false,
"userSelfEnrollmentEnabled": true,
"recoveryCodesEnabled": true,
"requiredRoles": ["owner", "admin"],
"availableFactors": ["totp", "recovery_code"]
}'Use role-based enforcement for privileged users:
{
"isEnabled": true,
"requireMfaForAllUsers": false,
"requireMfaForOwnersAndAdmins": true,
"userSelfEnrollmentEnabled": true,
"recoveryCodesEnabled": true,
"requiredRoles": ["owner", "admin"],
"availableFactors": ["totp", "recovery_code"]
}PUT replaces the whole organization policy. Always send every field, preferably by reading the current policy and changing only the intended values in your operator code. Omitting a Boolean binds it to false; a partial payload could require MFA while accidentally disabling enrollment or recovery. The Admin API also requires an operator session.
Open Auth Server > MFA in the dashboard.
The global MFA screen controls:
Organization-specific MFA policy is available through the admin API and startup seeding in this release. The dashboard global MFA screen is the operator UI for application-wide settings.
Hosted authorization-code login enforces MFA automatically. You do not write the challenge page.
After password, Email OTP, OIDC, SAML, signup, or organization selection:
Two-step verification.Add authenticator app with a QR code and manual setup secret.These states are intentionally separate. A normal mfa challenge never grants
authenticator enrollment, even if the caller has completed the password step.
Only a challenge whose stored policy decision requires first-time enrollment
can start mfa-enroll.
Issued tokens include amr claims for the primary and second factor:
amr: password
amr: totpRecovery-code login records:
amr: password
amr: recovery_codeMFA guessing protection is automatic. You do not need to configure a cache, rate-limiting service, or hosted dependency. SqlOS stores the enforcement state in the same database as the rest of AuthServer and applies it consistently to hosted, headless, and direct API verification.
By default, SqlOS:
Failures remain deliberately generic. A rejected code does not reveal whether the factor exists, was previously used, or was blocked by a limit. TOTP codes and recovery codes also remain single-use.
The defaults are suitable for most applications. Deployments with a documented abuse model can tune them without replacing the built-in enforcement:
builder.AddSqlOS<AppDbContext>(options =>
{
options.AuthServer.ConfigureMfa(mfa =>
{
mfa.Totp.MaxFailedAttemptsPerChallenge = 5;
mfa.Totp.MaxFailedAttemptsPerUser = 10;
mfa.Totp.MaxFailedAttemptsPerIp = 25;
mfa.Totp.FailedAttemptWindow = TimeSpan.FromMinutes(10);
});
});Headless auth uses the same state machine. Your UI must render the new views.
When a headless action returns a view model with view: "mfa", render a code input and post:
POST /sqlos/auth/headless/mfa/verify
{
"requestId": "req_123",
"mfaToken": "tmp_...",
"code": "123456"
}When it returns view: "mfa-enroll", render the QR code and enrollment fields. Forced enrollment view models include totpEnrollment with:
enrollmentTokenauthenticatorIdsecretprovisioningUriqrCodeDataUrlexpiresAtVerify enrollment:
POST /sqlos/auth/headless/mfa/totp/enroll/verify
{
"requestId": "req_123",
"mfaToken": "tmp_...",
"enrollmentToken": "tmp_...",
"code": "123456"
}On success SqlOS returns the same redirect result shape used by the rest of headless auth.
Send the requestId, mfaToken, and enrollmentToken from the same returned
view model. SqlOS binds all three to the user, organization, client, flow, and
authorization request. Mixing values from parallel tabs or separate sign-in
attempts is rejected before recovery codes or login artifacts change.
Direct password and signup APIs return MFA state instead of tokens when policy requires it.
{
"requiresOrganizationSelection": false,
"pendingAuthToken": null,
"organizations": [],
"tokens": null,
"requiresMfa": true,
"mfaToken": "tmp_...",
"requiresMfaEnrollment": false,
"mfaMethods": ["totp", "recovery_code"]
}Verify an existing factor:
POST /sqlos/auth/mfa/challenge/verify
{
"mfaToken": "tmp_...",
"code": "123456"
}For forced enrollment:
POST /sqlos/auth/mfa/challenge/totp/enroll/start
{
"mfaToken": "tmp_...",
"displayName": "Authenticator app"
}Then verify:
POST /sqlos/auth/mfa/challenge/totp/enroll/verify
{
"mfaToken": "tmp_...",
"enrollmentToken": "tmp_...",
"code": "123456"
}Call the forced-enrollment endpoints only when the same result has
requiresMfaEnrollment: true. A result with requiresMfaEnrollment: false
must use /mfa/challenge/verify with the user's existing authenticator or
recovery code; the first factor does not authorize factor replacement.
SqlOS exposes service methods for account pages in your app. The Retail example wires these through /api/mfa/* endpoints.
var status = await authService.GetMfaStatusAsync(userId, organizationId, ct);
var enrollment = await authService.StartTotpEnrollmentAsync(
userId,
new SqlOSTotpEnrollmentStartRequest("Authenticator app"),
organizationId,
ct);
var result = await authService.VerifyTotpEnrollmentAsync(
new SqlOSTotpEnrollmentVerifyRequest(enrollment.EnrollmentToken, code),
httpContext,
ct);After enrollment, show result.RecoveryCodes once and tell the user to store them. Recovery codes are single-use.
Account self-enrollment is deliberately separate from login-time required enrollment. Protect the account endpoint with an authenticated session and any recent-authentication rule your product requires. Its enrollment token cannot be paired with an MFA challenge, and a challenge-bound enrollment token cannot be verified as account self-enrollment.
SqlOS does not re-display confirmed TOTP secrets. That is deliberate.
For a new phone:
Do not implement recovery by showing the existing secret again. Treat replacement as a new enrollment.
TOTP secrets are protected with SqlOSCryptoService.ProtectSecret, which uses ASP.NET Data Protection when available.
Production deployments should configure a durable Data Protection key ring outside the application database. If Data Protection keys are lost, existing protected TOTP secrets cannot be unprotected.