This post shows how to implement phone (SMS) verification and two-factor authentication (2FA) using ASP.NET Core Identity. The solution integrates phone-based verification and 2FA mechanisms. The implementation uses ASP.NET Core Identity’s extensibility to incorporate SMS-based verification during user registration and login processes. SMS is no longer a recommended authentication method due to security risks but does provide a good solution for some business cases or user flows like onboarding phone users or phone applications, frontline workers with no desktop or other such solutions with limited security possibilities.
Code: https://github.com/damienbod/IdentityOidcPhone2fa
Setup
The ASP.NET Core Identity application integrates the SMS provider using the Identity PhoneNumberTokenProvider and an SMS verification service.
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("EmailSettings"));
builder.Services.AddTransient<IEmailSender, EmailSender>();
builder.Services.Configure<SmsOptions>(builder.Configuration.GetSection("SmsOptions"));
var authorization = Convert.ToBase64String(Encoding.ASCII.GetBytes(
$"{builder.Configuration["SmsOptions:Username"]}:{builder.Configuration["SmsOptions:Password"]}"));
builder.Services.AddHttpClient(Consts.SMSeColl, client =>
{
client.BaseAddress = new Uri($"{builder.Configuration["SmsOptions:Url"]}");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authorization);
});
builder.Services.AddScoped<SmsProvider>();
builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddTokenProvider<DataProtectorTokenProvider<ApplicationUser>>(TokenOptions.DefaultProvider)
.AddTokenProvider<AuthenticatorTokenProvider<ApplicationUser>>(TokenOptions.DefaultAuthenticatorProvider)
.AddTokenProvider<PhoneNumberTokenProvider<ApplicationUser>>(Consts.Phone)
.AddTokenProvider<EmailTokenProvider<ApplicationUser>>(Consts.Email);
The ApplicationUser needs some new properties to support multiple authentication methods. The properties are used to allow a user to use the selected authentication method or force an authentication on a OpenID Connect client.
public bool Phone2FAEnabled { get; set; }
public bool Email2FAEnabled { get; set; }
public bool AuthenticatorApp2FAEnabled { get; set; }
public bool Passkeys2FAEnabled { get; set; }
An SMS service are used to integrate the SMS, the SmsProvider class. In this demo, the eColl messaging service is used to send SMS. The implementation and the configuration would vary if you use a different service.
The SmsProvider is used to verify a phone number, to enable SMS 2FA and to force SMS 2FA. The service uses a HttpClient to access the SMS service rest API.
using IdentityProvider.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace IdentityProvider.Services;
public class SmsProvider
{
private readonly HttpClient _httpClient;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SmsOptions _smsOptions;
private readonly ILogger<SmsProvider> _logger;
private const string Message = "message";
public SmsProvider(IHttpClientFactory clientFactory,
UserManager<ApplicationUser> userManager,
IOptions<SmsOptions> smsOptions,
ILogger<SmsProvider> logger)
{
_httpClient = clientFactory.CreateClient(Consts.SMSeColl);
_userManager = userManager;
_smsOptions = smsOptions.Value;
_logger = logger;
}
public async Task<(bool Success, string? Error)> Send2FASmsAsync(ApplicationUser user, string phoneNumber)
{
var code = await _userManager.GenerateTwoFactorTokenAsync(user, Consts.Phone);
var ecallMessage = new EcallMessage
{
To = phoneNumber,
From = _smsOptions.Sender,
Content = new EcallContent
{
Text = $"2FA code: {code}"
}
};
var result = await _httpClient.PostAsJsonAsync(Message, ecallMessage);
string? messageResult;
if (result.IsSuccessStatusCode)
{
messageResult = await result.Content.ReadAsStringAsync();
}
else
{
_logger.LogWarning("Error sending SMS 2FA, {ReasonPhrase}", result.ReasonPhrase);
return (false, result.ReasonPhrase);
}
return (true, messageResult);
}
public async Task<(bool Success, string? Error)> StartVerificationAsync(ApplicationUser user, string phoneNumber)
{
var token = await _userManager.GenerateChangePhoneNumberTokenAsync(user, phoneNumber);
var ecallMessage = new EcallMessage
{
To = phoneNumber,
From = _smsOptions.Sender,
Content = new EcallContent
{
Text = $"Verify code: {token}"
}
};
var result = await _httpClient.PostAsJsonAsync(Message, ecallMessage);
string? messageResult;
if (result.IsSuccessStatusCode)
{
messageResult = await result.Content.ReadAsStringAsync();
}
else
{
_logger.LogWarning("Error sending SMS for phone Verification, {ReasonPhrase}", result.ReasonPhrase);
return (false, result.ReasonPhrase);
}
return (true, messageResult);
}
public async Task<bool> CheckVerificationAsync(ApplicationUser user, string phoneNumber, string verificationCode)
{
var is2faTokenValid = await _userManager
.VerifyChangePhoneNumberTokenAsync(user, verificationCode, phoneNumber);
return is2faTokenValid;
}
public async Task<(bool Success, string? Error)> EnableSms2FaAsync(ApplicationUser user, string phoneNumber)
{
var token = await _userManager.GenerateChangePhoneNumberTokenAsync(user, phoneNumber);
var message = $"Enable phone 2FA code: {token}";
var ecallMessage = new EcallMessage
{
To = phoneNumber,
From = _smsOptions.Sender,
Content = new EcallContent
{
Text = message
}
};
var result = await _httpClient.PostAsJsonAsync(Message, ecallMessage);
string? messageResult;
if (result.IsSuccessStatusCode)
{
messageResult = await result.Content.ReadAsStringAsync();
}
else
{
_logger.LogWarning("Error sending SMS to enable phone 2FA, {ReasonPhrase}", result.ReasonPhrase);
return (false, result.ReasonPhrase);
}
return (true, messageResult);
}
}
Flow 1: Verify phone
Once a user has authenticated with email and password, the user can verify a phone. To verify the phone, the user MUST be authenticated. If not, a malicious program may send multiple SMS and cause financial harm. The Add phone number link can be used to start the verification process.

The VerifyPhone Razor page allows the user to enter an mobile phone number to send the SMS. This should be validated for real phone numbers at least. The StartVerificationAsync method is used to send the SMS. The ASP.NET Core Identity method GenerateChangePhoneNumberTokenAsync is used to generate the challenge for the verification.
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var result = await _client.StartVerificationAsync(user, Input.PhoneNumber);
The UI is not styled, just uses the standard bootstrap styles.

The confirm Razor Page accepts the verification code which was sent to the phone and uses the VerifyAndProcessCode method to validate. The ASP.NET Core Identity VerifyChangePhoneNumberTokenAsync method is used to validate the code.
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
try
{
if (Input.PhoneNumber != null && Input.VerificationCode != null)
{
return await VerifyAndProcessCode(Input.PhoneNumber, Input.VerificationCode);
}
else
{
ModelState.AddModelError("", "Input.PhoneNumber or Input.VerificationCode missing");
}
}
catch (Exception)
{
ModelState.AddModelError("", "There was an error confirming the code, please check the verification code is correct and try again");
}
return Page();
}
private async Task<IActionResult> VerifyAndProcessCode(string phoneNumber, string code)
{
var applicationUser = await _userManager.GetUserAsync(User);
if (applicationUser != null)
{
var validCodeForUserSession = await _client.CheckVerificationAsync(applicationUser,
phoneNumber, code);
return await ProcessValidCode(applicationUser, validCodeForUserSession);
}
else
{
ModelState.AddModelError("", "No user");
return Page();
}
}
private async Task<IActionResult> ProcessValidCode(ApplicationUser applicationUser, bool validCodeForUserSession)
{
if (validCodeForUserSession)
{
var phoneNumber = await _userManager.GetPhoneNumberAsync(applicationUser);
if (Input.PhoneNumber != phoneNumber)
{
await _userManager.SetPhoneNumberAsync(applicationUser, Input.PhoneNumber);
}
applicationUser.PhoneNumberConfirmed = true;
var updateResult = await _userManager.UpdateAsync(applicationUser);
if (updateResult.Succeeded)
{
return RedirectToPage("ConfirmPhoneSuccess");
}
else
{
ModelState.AddModelError("", "There was an error confirming the verification code, please try again");
}
}
else
{
ModelState.AddModelError("", "There was an error confirming the verification code");
}
return Page();
}
The UI displays the input for the code and the number it was sent to.

Flow 2: Enable phone 2FA
Once the phone is verified, it can be used for an SMS 2FA.

The EnableSms2FaAsync method is used to enable the SMS 2FA.
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
if (user.PhoneNumber != Input.PhoneNumber)
{
ModelState.AddModelError("Input.PhoneNumber",
"Phone number does not match user user, please update or add phone in your profile");
}
await _smsVerifyClient.EnableSms2FaAsync(user, Input.PhoneNumber!);
return RedirectToPage("./VerifyPhone2Fa", new { Input.PhoneNumber });
The EnablePhone2Fa Razor page is used to validate the phone number before activating the 2FA.

The VerifyChangePhoneNumberTokenAsync is used to validate and the 2FA is activated.
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var verificationCode = Input.Code.Replace(" ", string.Empty).Replace("-", string.Empty);
var is2faTokenValid = await _userManager
.VerifyChangePhoneNumberTokenAsync(user, verificationCode, user.PhoneNumber!);
if (!is2faTokenValid)
{
ModelState.AddModelError("Input.Code", "Verification code is invalid.");
return Page();
}
await _userManager.SetTwoFactorEnabledAsync(user, true);
The Verify Phone 2FA Razor page displays the input field for the code.

Once activated, the user should persist some recovery codes.

Flow 3: SMS 2FA using phone
Next time the user authenticates, the SMS 2FA is required. The user can use multiple authentication methods, not only SMS. If possible, passkeys or strong authentication should be used.
if (user.Phone2FAEnabled)
{
IsPhone = true;
if (!user.AuthenticatorApp2FAEnabled)
{
await _smsVerifyClient
.Send2FASmsAsync(user, user.PhoneNumber!);
}
}
Further flows
Phone only authentication
Requires mass usage protection
Recover account using Phone authentication
Requires mass usage protection
Links
https://learn.microsoft.com/en-us/aspnet/core/security/authentication/2fa
https://github.com/andrewlock/TwilioSamples/blob/master/src/SendVerificationSmsDemo
Professionell Online SMS senden