
Blazor WASM Two-Factor Authentication with QR Codes and ASP.NET Identity
Author - Abdul Rahman (Bhai)
Blazor
34 Articles
Table of Contents
What we gonna do?
Ever locked yourself out of an account because you lost your phone? Two-factor authentication can feel like that friend who's overly protective—annoying until you realize they just saved you from disaster. In this article, let's learn about implementing two-factor authentication (2FA) with QR codes and TOTP (Time-based One-Time Password) in Blazor WebAssembly Standalone applications using ASP.NET Core Identity.
We'll build on top of the cookie-based authentication system and add a complete 2FA solution that includes QR code generation, authenticator app integration, and recovery codes. You'll learn how users can scan a QR code with apps like Microsoft Authenticator or Google Authenticator to enable 2FA protection for their accounts.
Why we gonna do?
The Security Gap in Single-Factor Authentication
Passwords alone are a single point of failure. Here's what happens without 2FA:
// ❌ Password-only login - One compromised password = complete account takeover
@code {
public async Task LoginUser()
{
var result = await Acct.LoginAsync(email, password);
if (result.Succeeded)
{
// User is in! But what if the password was:
// - Phished from a fake site
// - Leaked in a data breach
// - Guessed through brute force
// - Stolen via keylogger
}
}
}
With 2FA enabled, even if an attacker steals the password, they still can't access the account without the second factor—a time-sensitive code from the user's authenticator app.
Why TOTP Over SMS-Based 2FA?
TOTP (Time-based One-Time Password) is superior to SMS-based 2FA for several reasons:
- No SIM swap attacks - Attackers can't intercept codes by taking over your phone number
- Works offline - Authenticator apps generate codes without internet connectivity
- Faster and more reliable - No waiting for SMS delivery or carrier issues
- Free and standardized - Based on RFC 6238, compatible with any TOTP authenticator app
- Better user experience - QR code setup is instant and easy
The Complete 2FA Workflow
Here's how 2FA changes the authentication flow:
WITHOUT 2FA:
1. User enters email + password → Backend validates → Login successful
WITH 2FA:
1. User enters email + password → Backend validates credentials
2. Backend responds: "RequiresTwoFactor"
3. UI shows TOTP code input field
4. User opens authenticator app, gets 6-digit code (changes every 30 seconds)
5. User enters code → Backend validates TOTP → Login successful
OR (if using recovery code):
4. User lost their phone, uses one of 10 recovery codes
5. Backend validates recovery code → Login successful → Recovery codes regenerated
Recovery Codes: The Safety Net
What happens when users lose their phone or authenticator app? Without recovery codes, they're permanently locked out:
❌ Lost phone + No recovery codes = Permanent account lockout
✅ Lost phone + Recovery codes = Can still access account and re-configure 2FA
Recovery codes are single-use backup codes that users should print and store securely. When 2FA is enabled, the system generates 10 recovery codes. Each can be used once to bypass the TOTP requirement.
The QR Code Advantage
Before QR codes, users had to manually type a long shared secret key into their authenticator app—error-prone and frustrating. With QR code generation, setup is instant:
- User enables 2FA in your app
- Backend generates a unique shared secret
- Frontend creates QR code containing: organization name, user email, and shared secret
- User scans QR code with authenticator app
- Both backend and authenticator app now share the same secret
- Every 30 seconds, both generate the same 6-digit code using the secret + current time
How we gonna do?
Step 1: Add QR Code Generation Library
First, add a NuGet package to generate QR codes. We'll use Net.Codecrete.QrCodeGenerator, a lightweight .NET library:
dotnet add package Net.Codecrete.QrCodeGenerator
This package generates QR codes as SVG graphics, perfect for rendering in Blazor without external dependencies.
Step 2: Configure TOTP Organization Name
Set a recognizable organization name that appears in users' authenticator apps. Add this to wwwroot/appsettings.json:
{
"BackendUrl": "https://localhost:5001",
"FrontendUrl": "https://localhost:5002",
"TotpOrganizationName": "I Love DotNet"
}
Keep the organization name under 30 characters so it displays properly on narrow mobile screens. Users will see this name in their authenticator app next to the 6-digit code.
Step 3: Create 2FA Model Classes
Add three model classes to handle 2FA API communication. First, LoginResponse to detect 2FA requirements:
namespace BlazorWasmAuth.Identity.Models;
public class LoginResponse
{
public string? Type { get; set; }
public string? Title { get; set; }
public int Status { get; set; }
public string? Detail { get; set; }
}
Next, TwoFactorRequest for managing 2FA operations:
namespace BlazorWasmAuth.Identity.Models;
public class TwoFactorRequest
{
public bool? Enable { get; set; }
public string? TwoFactorCode { get; set; }
public bool? ResetSharedKey { get; set; }
public bool? ResetRecoveryCodes { get; set; }
public bool? ForgetMachine { get; set; }
}
Finally, TwoFactorResponse to receive 2FA status and data:
namespace BlazorWasmAuth.Identity.Models;
public class TwoFactorResponse
{
public string SharedKey { get; set; } = string.Empty;
public int RecoveryCodesLeft { get; set; } = 0;
public string[] RecoveryCodes { get; set; } = [];
public bool IsTwoFactorEnabled { get; set; }
public bool IsMachineRemembered { get; set; }
public string[] ErrorList { get; set; } = [];
}
Step 4: Extend IAccountManagement Interface
Add method signatures for 2FA operations to your IAccountManagement interface:
// Add to IAccountManagement.cs
public Task<FormResult> LoginTwoFactorCodeAsync(
string email,
string password,
string twoFactorCode);
public Task<FormResult> LoginTwoFactorRecoveryCodeAsync(
string email,
string password,
string twoFactorRecoveryCode);
public Task<TwoFactorResponse> TwoFactorRequestAsync(
TwoFactorRequest twoFactorRequest);
Step 5: Update CookieAuthenticationStateProvider
Add the System.Text.Json.Serialization namespace and update JSON serializer options to ignore null values:
using System.Text.Json.Serialization;
// In CookieAuthenticationStateProvider class
private readonly JsonSerializerOptions jsonSerializerOptions =
new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
Replace the LoginAsync method to detect 2FA requirements:
public async Task<FormResult> LoginAsync(string email, string password)
{
try
{
using var result = await httpClient.PostAsJsonAsync(
"login?useCookies=true", new
{
email,
password
});
if (result.IsSuccessStatusCode)
{
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
return new FormResult { Succeeded = true };
}
else if (result.StatusCode == HttpStatusCode.Unauthorized)
{
using var responseJson = await result.Content.ReadAsStringAsync();
var response = JsonSerializer.Deserialize<LoginResponse>(
responseJson, jsonSerializerOptions);
// Check if 2FA is required
if (response?.Detail == "RequiresTwoFactor")
{
return new FormResult
{
Succeeded = false,
ErrorList = [ "RequiresTwoFactor" ]
};
}
}
}
catch (Exception ex)
{
logger.LogError(ex, "Login failed");
}
return new FormResult
{
Succeeded = false,
ErrorList = [ "Invalid email and/or password." ]
};
}
Add LoginTwoFactorCodeAsync to handle TOTP code authentication:
public async Task<FormResult> LoginTwoFactorCodeAsync(
string email, string password, string twoFactorCode)
{
try
{
using var result = await httpClient.PostAsJsonAsync(
"login?useCookies=true", new
{
email,
password,
twoFactorCode
});
if (result.IsSuccessStatusCode)
{
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
return new FormResult { Succeeded = true };
}
}
catch (Exception ex)
{
logger.LogError(ex, "Two-factor login failed");
}
return new FormResult
{
Succeeded = false,
ErrorList = [ "Invalid two-factor code." ]
};
}
Add LoginTwoFactorRecoveryCodeAsync for recovery code authentication:
public async Task<FormResult> LoginTwoFactorRecoveryCodeAsync(
string email,
string password,
string twoFactorRecoveryCode)
{
try
{
using var result = await httpClient.PostAsJsonAsync(
"login?useCookies=true", new
{
email,
password,
twoFactorRecoveryCode
});
if (result.IsSuccessStatusCode)
{
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
return new FormResult { Succeeded = true };
}
}
catch (Exception ex)
{
logger.LogError(ex, "Recovery code login failed");
}
return new FormResult
{
Succeeded = false,
ErrorList = [ "Invalid recovery code." ]
};
}
Add TwoFactorRequestAsync to manage all 2FA operations:
public async Task<TwoFactorResponse> TwoFactorRequestAsync(
TwoFactorRequest twoFactorRequest)
{
string[] defaultDetail =
[ "An unknown error prevented two-factor authentication." ];
using var response = await httpClient.PostAsJsonAsync(
"manage/2fa",
twoFactorRequest,
jsonSerializerOptions);
if (response.IsSuccessStatusCode)
{
return await response.Content
.ReadFromJsonAsync<TwoFactorResponse>() ??
new()
{
ErrorList = [ "There was an error processing the request." ]
};
}
// Parse error details from response
var details = await response.Content.ReadAsStringAsync();
var problemDetails = JsonDocument.Parse(details);
var errors = new List<string>();
var errorList = problemDetails.RootElement.GetProperty("errors");
foreach (var errorEntry in errorList.EnumerateObject())
{
if (errorEntry.Value.ValueKind == JsonValueKind.String)
{
errors.Add(errorEntry.Value.GetString()!);
}
else if (errorEntry.Value.ValueKind == JsonValueKind.Array)
{
errors.AddRange(
errorEntry.Value.EnumerateArray()
.Select(e => e.GetString() ?? string.Empty)
.Where(e => !string.IsNullOrEmpty(e)));
}
}
return new TwoFactorResponse
{
ErrorList = problemDetails == null ? defaultDetail : [.. errors]
};
}
Step 6: Create Enhanced Login Component
Replace the Login component to support both regular login and 2FA code entry:
@page "/login"
@using System.ComponentModel.DataAnnotations
@using BlazorWasmAuth.Identity
@using BlazorWasmAuth.Identity.Models
@inject IAccountManagement Acct
@inject NavigationManager Navigation
<PageTitle>Login</PageTitle>
<h1>Login</h1>
<AuthorizeView>
<Authorized>
<div class="alert alert-success">
You're logged in as @context.User.Identity?.Name.
</div>
</Authorized>
<NotAuthorized>
@foreach (var error in formResult.ErrorList)
{
<div class="alert alert-danger">@error</div>
}
<EditForm Model="Input" method="post" OnValidSubmit="LoginUser">
<DataAnnotationsValidator />
<!-- Email/Password fields (hidden when 2FA is required) -->
<div style="display:@(requiresTwoFactor ? "none" : "block")">
<div class="form-floating mb-3">
<InputText @bind-Value="Input.Email"
class="form-control"
autocomplete="username"
placeholder="name@example.com" />
<label>Email</label>
<ValidationMessage For="() => Input.Email" />
</div>
<div class="form-floating mb-3">
<InputText type="password"
@bind-Value="Input.Password"
class="form-control"
autocomplete="current-password"
placeholder="password" />
<label>Password</label>
<ValidationMessage For="() => Input.Password" />
</div>
</div>
<!-- 2FA code field (shown when 2FA is required) -->
<div style="display:@(requiresTwoFactor ? "block" : "none")">
<div class="form-floating mb-3">
<InputText @bind-Value="Input.TwoFactorCodeOrRecoveryCode"
class="form-control"
autocomplete="off"
placeholder="###### or #####-#####" />
<label>Two-factor Code or Recovery Code</label>
<ValidationMessage
For="() => Input.TwoFactorCodeOrRecoveryCode" />
</div>
</div>
<button type="submit" class="btn btn-primary">Log in</button>
</EditForm>
</NotAuthorized>
</AuthorizeView>
@code {
private FormResult formResult = new();
private bool requiresTwoFactor;
[SupplyParameterFromForm]
private InputModel Input { get; set; } = new();
[SupplyParameterFromQuery]
private string? ReturnUrl { get; set; }
public async Task LoginUser()
{
if (requiresTwoFactor)
{
if (!string.IsNullOrEmpty(Input.TwoFactorCodeOrRecoveryCode))
{
// 6-digit code = TOTP, 11-character = recovery code
if (Input.TwoFactorCodeOrRecoveryCode.Length == 6)
{
formResult = await Acct.LoginTwoFactorCodeAsync(
Input.Email,
Input.Password,
Input.TwoFactorCodeOrRecoveryCode);
}
else
{
formResult = await Acct.LoginTwoFactorRecoveryCodeAsync(
Input.Email,
Input.Password,
Input.TwoFactorCodeOrRecoveryCode);
}
}
}
else
{
formResult = await Acct.LoginAsync(Input.Email, Input.Password);
requiresTwoFactor =
formResult.ErrorList.Contains("RequiresTwoFactor");
Input.TwoFactorCodeOrRecoveryCode = string.Empty;
if (requiresTwoFactor)
{
formResult.ErrorList = [];
}
}
if (formResult.Succeeded && !string.IsNullOrEmpty(ReturnUrl))
{
Navigation.NavigateTo(ReturnUrl);
}
}
private sealed class InputModel
{
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
[Required]
[DataType(DataType.Password)]
public string Password { get; set; } = string.Empty;
[RegularExpression(@"^([0-9]{6})|([A-Z0-9]{5}[-]{1}[A-Z0-9]{5})$",
ErrorMessage = "Must be 6-digit code (######) or " +
"recovery code (#####-#####)")]
public string TwoFactorCodeOrRecoveryCode { get; set; } = string.Empty;
}
}
Step 7: Create Recovery Codes Display Component
Add a component to display recovery codes to users when 2FA is enabled:
<h3>Recovery Codes</h3>
<div class="alert alert-warning" role="alert">
<p>
<strong>Put these codes in a safe place.</strong>
</p>
<p>
If you lose your device and don't have an unused
recovery code, you can't access your account.
</p>
</div>
<div class="row">
<div class="col-md-12">
@foreach (var recoveryCode in RecoveryCodes)
{
<div>
<code class="recovery-code">@recoveryCode</code>
</div>
}
</div>
</div>
@code {
[Parameter]
public string[] RecoveryCodes { get; set; } = [];
}
Step 8: Create the 2FA Management Page
Create the main component for managing 2FA with QR code generation:
@page "/manage-2fa"
@using System.ComponentModel.DataAnnotations
@using System.Globalization
@using System.Text.Encodings.Web
@using Net.Codecrete.QrCodeGenerator
@using BlazorWasmAuth.Identity
@using BlazorWasmAuth.Identity.Models
@attribute [Authorize]
@inject IAccountManagement Acct
@inject IConfiguration Config
<PageTitle>Manage 2FA</PageTitle>
<h1>Manage Two-factor Authentication</h1>
<hr />
@if (loading)
{
<p>Loading...</p>
}
else if (twoFactorResponse is not null)
{
@foreach (var error in twoFactorResponse.ErrorList)
{
<div class="alert alert-danger">@error</div>
}
@if (twoFactorResponse.IsTwoFactorEnabled)
{
<div class="alert alert-success">
Two-factor authentication is enabled for your account.
</div>
<button @onclick="Disable2FA" class="btn btn-primary m-1">
Disable 2FA
</button>
@if (twoFactorResponse.RecoveryCodes is null)
{
<div class="m-1">
Recovery Codes Remaining: @twoFactorResponse.RecoveryCodesLeft
</div>
<button @onclick="GenerateNewCodes" class="btn btn-primary m-1">
Generate New Recovery Codes
</button>
}
else
{
<ShowRecoveryCodes RecoveryCodes="twoFactorResponse.RecoveryCodes" />
}
}
else
{
<h3>Configure authenticator app</h3>
<ol>
<li>
<p>Download a two-factor authenticator app like:</p>
<ul>
<li>Microsoft Authenticator for
<a href="https://go.microsoft.com/fwlink/?Linkid=825072">
Android
</a> and
<a href="https://go.microsoft.com/fwlink/?Linkid=825073">
iOS
</a>
</li>
<li>Google Authenticator for
<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">
Android
</a> and
<a href="https://itunes.apple.com/us/app/google-authenticator/id388497605?mt=8">
iOS
</a>
</li>
</ul>
</li>
<li>
<p>
Scan the QR Code or enter this key
<kbd>@twoFactorResponse.SharedKey</kbd>
into your authenticator app.
</p>
<div>
<svg xmlns="http://www.w3.org/2000/svg"
height="300" width="300"
stroke="none" version="1.1"
viewBox="0 0 50 50">
<rect width="300" height="300" fill="#ffffff" />
<path d="@svgGraphicsPath" fill="#000000" />
</svg>
</div>
</li>
<li>
<p>Enter the verification code from your authenticator app:</p>
<EditForm Model="Input" OnValidSubmit="OnValidSubmitAsync">
<DataAnnotationsValidator />
<div class="form-floating mb-3">
<InputText @bind-Value="Input.Code"
class="form-control"
autocomplete="off"
placeholder="Enter the code" />
<label>Verification Code</label>
<ValidationMessage For="() => Input.Code" />
</div>
<button type="submit" class="btn btn-primary">
Verify
</button>
</EditForm>
</li>
</ol>
}
}
@code {
private TwoFactorResponse twoFactorResponse = new();
private bool loading = true;
private string? svgGraphicsPath;
[SupplyParameterFromForm]
private InputModel Input { get; set; } = new();
[CascadingParameter]
private Task<AuthenticationState>? authenticationState { get; set; }
protected override async Task OnInitializedAsync()
{
twoFactorResponse = await Acct.TwoFactorRequestAsync(new());
svgGraphicsPath = await GetQrCode(twoFactorResponse.SharedKey);
loading = false;
}
private async Task<string> GetQrCode(string sharedKey)
{
if (authenticationState is not null && !string.IsNullOrEmpty(sharedKey))
{
var authState = await authenticationState;
var email = authState?.User?.Identity?.Name!;
// Generate TOTP URI for authenticator apps
var uri = string.Format(
CultureInfo.InvariantCulture,
"otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6",
UrlEncoder.Default.Encode(Config["TotpOrganizationName"]!),
email,
twoFactorResponse.SharedKey);
var qr = QrCode.EncodeText(uri, QrCode.Ecc.Medium);
return qr.ToGraphicsPath();
}
return string.Empty;
}
private async Task Disable2FA()
{
await Acct.TwoFactorRequestAsync(new() { ForgetMachine = true });
twoFactorResponse =
await Acct.TwoFactorRequestAsync(new() { ResetSharedKey = true });
svgGraphicsPath = await GetQrCode(twoFactorResponse.SharedKey);
}
private async Task GenerateNewCodes()
{
twoFactorResponse =
await Acct.TwoFactorRequestAsync(new() { ResetRecoveryCodes = true });
}
private async Task OnValidSubmitAsync()
{
twoFactorResponse = await Acct.TwoFactorRequestAsync(
new()
{
Enable = true,
TwoFactorCode = Input.Code
});
Input.Code = string.Empty;
// Generate recovery codes when enabling 2FA
if (twoFactorResponse.RecoveryCodes is null ||
twoFactorResponse.RecoveryCodes.Length == 0)
{
await GenerateNewCodes();
}
}
private sealed class InputModel
{
[Required]
[RegularExpression(@"^([0-9]{6})$",
ErrorMessage = "Must be a six-digit code (######)")]
[DataType(DataType.Text)]
public string Code { get; set; } = string.Empty;
}
}
Step 9: Add Navigation Link
Add a link to the navigation menu for authenticated users to access 2FA management:
<!-- In NavMenu.razor, inside <Authorized> section -->
<div class="nav-item px-3">
<NavLink class="nav-link" href="manage-2fa">
<span class="bi bi-key" aria-hidden="true"></span> Manage 2FA
</NavLink>
</div>
Step 10: Understanding the TOTP URI Format
The QR code contains a specially formatted URI that authenticator apps understand:
otpauth://totp/{OrganizationName}:{UserEmail}?secret={SharedKey}&issuer={OrganizationName}&digits=6
Example:
otpauth://totp/I%20Love%20DotNet:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=I%20Love%20DotNet&digits=6
Breaking it down:
- otpauth://totp/ : Indicates this is a TOTP secret
- I%20Love%20DotNet : Organization name (URL-encoded)
- user@example.com : User's email
- JBSWY3DPEHPK3PXP : Shared secret (Base32 encoded)
- issuer : Organization name again (displayed in app)
- digits=6 : Generate 6-digit codes
Step 11: Never Require 2FA on Every Login (Optional)
By default, ASP.NET Core Identity remembers the machine after successful 2FA login. To require 2FA on every login, call ForgetMachine after authentication:
// In Login component, after successful TOTP login
if (Input.TwoFactorCodeOrRecoveryCode.Length == 6)
{
formResult = await Acct.LoginTwoFactorCodeAsync(
Input.Email, Input.Password,
Input.TwoFactorCodeOrRecoveryCode);
// Always require 2FA on next login
if (formResult.Succeeded)
{
var forgetMachine =
await Acct.TwoFactorRequestAsync(new() { ForgetMachine = true });
}
}
This is more secure but less convenient for users. Most apps remember the machine for 30 days.
Step 12: Handling TOTP Time Skew Issues
TOTP depends on accurate time synchronization between the server and the authenticator app device. Codes are valid for only 30 seconds:
Time Skew Problems:
- Server time: 10:30:15 UTC
- User device: 10:29:45 UTC (30 seconds behind)
- Result: User's code is from the previous 30-second window → Authentication fails
Solutions:
1. Ensure server time is synced with NTP (Network Time Protocol)
2. Advise users to enable automatic time on their devices
3. ASP.NET Core Identity allows ±1 time window tolerance by default
If users report frequent 2FA failures, check server time synchronization first.
Summary
In this article, we implemented a complete two-factor authentication system with QR codes and TOTP in Blazor WebAssembly Standalone applications. Here are the key takeaways:
- TOTP is superior to SMS-based 2FA - More secure, works offline, and prevents SIM swap attacks
- QR codes simplify setup - Users scan once instead of typing long secret keys
- Recovery codes are essential - Generate 10 single-use codes to prevent permanent lockouts
- Login flow changes with 2FA - Detect "RequiresTwoFactor" response and show code input field
- Three authentication methods - Password only, password + TOTP code, or password + recovery code
- Shared secret synchronization - Both backend and authenticator app generate the same code using the secret + time
- Time accuracy matters - TOTP codes expire every 30 seconds; server and client clocks must be synchronized
- Machine memory is optional - Can remember devices or require 2FA on every login
This implementation gives you bank-level security for your Blazor WASM apps while maintaining excellent user experience. The combination of QR code setup, authenticator app integration, and recovery codes creates a robust 2FA solution that protects user accounts even when passwords are compromised.