Skip to main content
Version: 2026.04.0

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:

appsettings.json
{
"PrimusAuth": {
"Mfa": {
"Enabled": true,
"Issuer": "MyApp",
"PendingTokenExpiryMinutes": 5,
"EnrollTokenExpiryMinutes": 10,
"BackupCodeCount": 8,
"RequireAllUsers": false,
"EnableSmsMfa": false
}
}
}
OptionDefaultAllowed valuesDescription
Enabledfalsetrue | falseMust be true to activate MFA endpoints and the mid-login gate.
Issuer"Primus"Any stringLabel shown in authenticator apps next to the TOTP entry.
PendingTokenExpiryMinutes5Positive integerLifetime (minutes) of the token issued during the login MFA gate.
EnrollTokenExpiryMinutes10Positive integerLifetime (minutes) of the enrollment token returned by POST /api/auth/mfa/enroll.
BackupCodeCount8Positive integerNumber of one-time backup codes generated at enrollment.
RequireAllUsersfalsetrue | falseWhen true, all users must enroll in MFA before signing in. Users without MFA enrolled receive an mfa_setup_required response.
EnableSmsMfafalsetrue | falseAllow 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)

  1. Enable SMS MFA:
{
"PrimusAuth": {
"Mfa": {
"Enabled": true,
"EnableSmsMfa": true
}
}
}
  1. Add Twilio settings (prefer user-secrets / env vars):
{
"TwilioSms": {
"AccountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"AuthToken": "your-auth-token",
"FromNumber": "+15551234567",
"MessagingServiceSid": ""
}
}
  1. 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>());
}
  1. SMS MFA endpoint flow:
  • POST /api/auth/mfa/sms/enroll with { "phoneNumber": "+15551234567" }
  • POST /api/auth/mfa/sms/enroll/confirm with { "phoneNumber": "+15551234567", "code": "123456" }
  • POST /api/auth/mfa/sms/verify with { "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 = true
  • Mfa.Issuer = "MyApp"
  • Mfa.PendingTokenExpiryMinutes = 5
  • Mfa.BackupCodeCount = 8

2) Test local login + MFA challenge (email/password)

  1. POST /api/auth/login with valid credentials.
  2. Expected: 202 Accepted with mfaRequired: true and mfaPendingToken.
  3. POST /api/auth/mfa/verify with { mfaPendingToken, code }.
  4. Expected: 200 OK and authenticated session.

Negative checks:

  • Use an invalid TOTP → expect 401.
  • Wait more than PendingTokenExpiryMinutes (5 min) → pending token should expire.

3) Test enrollment lifecycle

  1. Sign in.
  2. POST /api/auth/mfa/enroll and scan otpauthUri in authenticator app.
  3. POST /api/auth/mfa/enroll/confirm with first valid code.
  4. Confirm backupCodes count is 8.
  5. Use one backup code once via POST /api/auth/mfa/verify.
  6. 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

info

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:

Program.cs
// 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:

Program.cs
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.

note

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 code field. 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

PropertyValueConfigurable?
AlgorithmHMAC-SHA1 (RFC 6238 default)No — hardcoded
Digits6No — hardcoded
Step30 secondsNo — hardcoded
Window±1 step (90-second tolerance for clock skew)No — hardcoded
Pending tokenDataProtection-encrypted, time-limitedYes — PendingTokenExpiryMinutes
Enroll tokenDataProtection-encrypted, time-limitedYes — EnrollTokenExpiryMinutes
Backup codes8-char, alphanumeric, unambiguous charset, single-useYes — 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 IMfaStore implementations, data protection, and HA clustering
  • Database Setup — persist MFA enrollments and backup codes with EF Core