Multi-Factor Authentication (TOTP)
Primus Identity Broker ships built-in TOTP (Time-based One-Time Password) MFA. It works with any standard authenticator app — Google Authenticator, Microsoft Authenticator, Authy, 1Password, and others.
No extra NuGet packages are required. The TOTP engine is built into PrimusMfaService using System.Security.Cryptography and Microsoft.AspNetCore.DataProtection.
Enable MFA
Add the Mfa section to your configuration and set Enabled: true:
{
"PrimusAuth": {
"Mfa": {
"Enabled": true,
"Issuer": "MyApp",
"PendingTokenExpiryMinutes": 5,
"EnrollTokenExpiryMinutes": 10,
"BackupCodeCount": 8,
"RequireAllUsers": false,
"EnableSmsMfa": false
}
}
}
| Option | Default | Allowed values | Description |
|---|---|---|---|
Enabled | false | true | false | Must be true to activate MFA endpoints and the mid-login gate. |
Issuer | "Primus" | Any string | Label shown in authenticator apps next to the TOTP entry. |
PendingTokenExpiryMinutes | 5 | Positive integer | Lifetime (minutes) of the token issued during the login MFA gate. |
EnrollTokenExpiryMinutes | 10 | Positive integer | Lifetime (minutes) of the enrollment token returned by POST /api/auth/mfa/enroll. |
BackupCodeCount | 8 | Positive integer | Number of one-time backup codes generated at enrollment. |
RequireAllUsers | false | true | false | When true, all users must enroll in MFA before signing in. Users without MFA enrolled receive an mfa_setup_required response. |
EnableSmsMfa | false | true | false | Allow SMS as an additional MFA second factor. |
Real SMS MFA (production-style)
EnableSmsMfa: true only enables SMS MFA endpoints. You still need a real IPrimusSmsSender implementation (for example Twilio, AWS SNS, Azure Communication Services) so OTP messages are actually delivered.
If no sender is registered, the broker uses NullSmsSender and no SMS reaches end users.
Twilio example (end-to-end)
- Enable SMS MFA:
{
"PrimusAuth": {
"Mfa": {
"Enabled": true,
"EnableSmsMfa": true
}
}
}
- Add Twilio settings (prefer user-secrets / env vars):
{
"TwilioSms": {
"AccountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"AuthToken": "your-auth-token",
"FromNumber": "+15551234567",
"MessagingServiceSid": ""
}
}
- Register a Twilio-backed sender in
Program.cs:
builder.Services.Configure<TwilioSmsOptions>(builder.Configuration.GetSection("TwilioSms"));
var twilioSmsOptions = builder.Configuration.GetSection("TwilioSms").Get<TwilioSmsOptions>() ?? new TwilioSmsOptions();
if (twilioSmsOptions.IsConfigured)
{
builder.Services.AddHttpClient<TwilioPrimusSmsSender>();
builder.Services.AddScoped<IPrimusSmsSender>(sp => sp.GetRequiredService<TwilioPrimusSmsSender>());
}
- SMS MFA endpoint flow:
POST /api/auth/mfa/sms/enrollwith{ "phoneNumber": "+15551234567" }POST /api/auth/mfa/sms/enroll/confirmwith{ "phoneNumber": "+15551234567", "code": "123456" }POST /api/auth/mfa/sms/verifywith{ "code": "123456" }
Notes for enterprise deployments
- Keep numbers in E.164 format (
+15551234567). - SMS can be used as fallback, but TOTP is stronger against SIM-swap attacks.
- If you already enforce MFA at your IdP (for example Azure Conditional Access), decide whether app-level SMS MFA is required or redundant for your risk model.
Test plan for this configuration
If you are using:
"Mfa": {
"Enabled": true,
"Issuer": "MyApp",
"PendingTokenExpiryMinutes": 5,
"EnrollTokenExpiryMinutes": 10,
"BackupCodeCount": 8,
"RequireAllUsers": false,
"EnableSmsMfa": false
}
use the checks below.
1) Verify MFA is active
Call:
GET /api/auth/__auth/diagnostics
Confirm:
Mfa.Enabled = trueMfa.Issuer = "MyApp"Mfa.PendingTokenExpiryMinutes = 5Mfa.BackupCodeCount = 8
2) Test local login + MFA challenge (email/password)
POST /api/auth/loginwith valid credentials.- Expected:
202 AcceptedwithmfaRequired: trueandmfaPendingToken. POST /api/auth/mfa/verifywith{ mfaPendingToken, code }.- Expected:
200 OKand authenticated session.
Negative checks:
- Use an invalid TOTP → expect
401. - Wait more than
PendingTokenExpiryMinutes(5 min) → pending token should expire.
3) Test enrollment lifecycle
- Sign in.
POST /api/auth/mfa/enrolland scanotpauthUriin authenticator app.POST /api/auth/mfa/enroll/confirmwith first valid code.- Confirm
backupCodescount is8. - Use one backup code once via
POST /api/auth/mfa/verify. - Reuse same backup code again → should fail (single-use).
4) Test SSO behavior with RequireAllUsers: false
For Azure/Auth0/Okta/Google/SAML sign-ins:
- SSO sign-in should complete even if user has not enrolled in local TOTP yet.
- Provider-side MFA (for example Azure Conditional Access MFA) remains independent and can still be enforced by the IdP.
5) What changes if RequireAllUsers becomes true
If you later set RequireAllUsers: true:
- users without MFA enrollment are blocked at sign-in completion,
- response includes
mfa_setup_required, - user must enroll before normal login can complete.
MFA Store — Where Enrollment Data Lives
MFA is off by default. If you are not using MFA, you do not need to configure a store or add any code — skip this section entirely.
When Mfa.Enabled is true, the broker needs somewhere to persist each user's TOTP secret and backup codes. Two paths are available out of the box:
Getting started — use the built-in in-memory store
The broker auto-registers a NullMfaStore (stores nothing, MFA always disabled). For local development and integration tests the package ships InMemoryMfaStore — a thread-safe in-memory implementation that requires no database:
// Override the default NullMfaStore with the built-in dev/test store
builder.Services.AddSingleton<IPrimusMfaStore, InMemoryMfaStore>();
builder.Services.AddPrimusAuthBroker(builder.Configuration, builder.Environment.IsDevelopment());
Enrollment data is held in memory and is cleared on every restart — suitable for local dev and integration tests only.
Production — persistent store
For production, implement IPrimusMfaStore against your database and register it:
builder.Services.AddScoped<IPrimusMfaStore, MyEfMfaStore>(); // your implementation
builder.Services.AddPrimusAuthBroker(builder.Configuration, builder.Environment.IsDevelopment());
IPrimusMfaStore is database-agnostic — implement it using any EF Core provider: SQL Server, PostgreSQL, SQLite, MySQL, or any other provider your project already uses.
The interface requires six methods — all straightforward read/write operations against a single table:
public interface IPrimusMfaStore
{
Task<bool> IsMfaEnabledAsync(string userId, CancellationToken ct = default);
Task<string?> GetTotpSecretAsync(string userId, CancellationToken ct = default);
Task<IReadOnlyList<string>> GetBackupCodesAsync(string userId, CancellationToken ct = default);
Task EnableMfaAsync(string userId, string totpSecretBase32, IReadOnlyList<string> backupCodes, CancellationToken ct = default);
Task DisableMfaAsync(string userId, CancellationToken ct = default);
Task<bool> ConsumeBackupCodeAsync(string userId, string code, CancellationToken ct = default);
}
Login Flow (Two-Phase)
When MFA is enabled and a user has enrolled, the normal POST /api/auth/login no longer signs in immediately.
The two-phase flow (202 response) applies only to local email/password login at POST /api/auth/login. SSO providers (Azure AD, Auth0, Okta, Google, and SAML) handle MFA within the provider's own flow and never return 202. When RequireAllUsers: true is set, SSO users who have not enrolled are blocked with mfa_setup_required instead of completing sign-in.
Phase 1 — Submit credentials
POST /api/auth/login
Content-Type: application/json
{ "email": "alice@example.com", "password": "s3cr3t" }
Response if MFA is required — HTTP 202:
{
"mfaRequired": true,
"mfaPendingToken": "<encrypted-short-lived-token>",
"message": "Complete the MFA challenge at POST /api/auth/mfa/verify"
}
Phase 2 — Submit the TOTP code
POST /api/auth/mfa/verify
Content-Type: application/json
{
"mfaPendingToken": "<token from phase 1>",
"code": "123456"
}
Success — HTTP 200:
{
"id": "usr_123",
"email": "alice@example.com",
"role": "User",
"provider": "local"
}
The auth cookie is set on success, completing the sign-in.
You can also submit an 8-character backup code in the
codefield. Each backup code can only be used once.
TOTP Enrollment
Step 1 — Initiate enrollment
The user must already be signed in — the session cookie must be present. If the user is not yet signed in, call POST /api/auth/login first to obtain a session, then proceed with enrollment.
POST /api/auth/mfa/enroll
Authorization: Cookie (authenticated)
Response:
{
"secret": "JBSWY3DPEHPK3PXP",
"otpauthUri": "otpauth://totp/MyApp:alice%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp&algorithm=SHA1&digits=6&period=30",
"enrollToken": "<short-lived-token>",
"issuer": "MyApp"
}
Display the otpauthUri as a QR code so the user can scan it with their authenticator app.
Step 2 — Confirm with first code
POST /api/auth/mfa/enroll/confirm
Content-Type: application/json
{
"enrollToken": "<token from step 1>",
"code": "654321"
}
Success — HTTP 200:
{
"backupCodes": [
"ABCD1234",
"EFGH5678",
"..."
]
}
Store the backup codes securely. They are shown only once.
Disable MFA
POST /api/auth/mfa/disable
Content-Type: application/json
Authorization: Cookie (authenticated)
{ "code": "123456" }
A valid current TOTP code is required to prevent accidental removal.
Success — HTTP 200:
{ "success": true }
Token Refresh
The session cookie does not refresh automatically. Call this endpoint explicitly before the session expires to extend it without requiring the user to log in again:
POST /api/auth/refresh
Authorization: Cookie (authenticated)
Response — HTTP 200:
{
"email": "alice@example.com",
"expiresAt": "2025-08-01T12:00:00.0000000+00:00"
}
Security Design
| Property | Value | Configurable? |
|---|---|---|
| Algorithm | HMAC-SHA1 (RFC 6238 default) | No — hardcoded |
| Digits | 6 | No — hardcoded |
| Step | 30 seconds | No — hardcoded |
| Window | ±1 step (90-second tolerance for clock skew) | No — hardcoded |
| Pending token | DataProtection-encrypted, time-limited | Yes — PendingTokenExpiryMinutes |
| Enroll token | DataProtection-encrypted, time-limited | Yes — EnrollTokenExpiryMinutes |
| Backup codes | 8-char, alphanumeric, unambiguous charset, single-use | Yes — BackupCodeCount |
Pending and enroll tokens are encrypted using ASP.NET Core Data Protection — they cannot be tampered with and expire automatically without a database lookup.
Full JWT tokens and TOTP secrets are never logged. The broker follows the Primus no-PII-in-logs rule.
Diagnostics
MFA configuration (Enabled, Issuer, PendingTokenExpiryMinutes, BackupCodeCount) is included in the response from the built-in diagnostics endpoint at GET /api/auth/__auth/diagnostics. This endpoint is registered only when ASPNETCORE_ENVIRONMENT is Development — it is automatically disabled in all other environments. See Endpoint Reference — Diagnostics for the full response shape.
Next Steps
- Integration Guide — complete setup walkthrough from install to first authenticated request
- Endpoint Reference — all MFA endpoints (enroll, verify, disable, backup codes) with full request and response shapes
- Advanced Configuration — custom
IMfaStoreimplementations, data protection, and HA clustering - Database Setup — persist MFA enrollments and backup codes with EF Core