Getting Started with User Management
In this tutorial, you’ll build a complete OTP (one-time password) login flow from scratch using Duende User Management on top of IdentityServer. By the end, you’ll have a working ASP.NET Core Razor Pages app where IdentityServer handles authentication and users log in with their email address and a one-time code.
The tutorial is split into two phases:
- Phase 1 scaffolds IdentityServer with User Management wired in.
- Phase 2 builds the OTP login pages that drive the actual user experience.
Prerequisites
Section titled “Prerequisites”- .NET 10 SDK or later
- A code editor (e.g. Visual Studio, VS Code, JetBrains Rider)
Phase 1: Scaffold IdentityServer with User Management
Section titled “Phase 1: Scaffold IdentityServer with User Management”Create the Project
Section titled “Create the Project”Run the following commands to scaffold a new ASP.NET Core web app and move into its directory:
dotnet new webapp -o OtpIdentityServer && cd OtpIdentityServerThe webapp template gives you a Razor Pages project with a Program.cs entry point, a Pages/ folder with a sample Index page, and the standard appsettings.json configuration files.
Add NuGet Packages
Section titled “Add NuGet Packages”Add the IdentityServer, IdentityServer User Management, and a storage NuGet package:
dotnet add package Duende.IdentityServerdotnet add package Duende.UserManagement.IdentityServer8dotnet add package Duende.Storage.SqliteDuende.IdentityServer is the core IdentityServer package. Duende.UserManagement.IdentityServer8 adds the User Management integration on top of it. The storage package provides the backing store for user data.
Three storage adapters are available:
| Package | Database | Notes |
|---|---|---|
Duende.Storage.Postgresql | PostgreSQL | Recommended for production. Requires an NpgsqlDataSource registered in DI. |
Duende.Storage.Mssql | SQL Server | Production-ready for Microsoft/Windows environments. |
Duende.Storage.Sqlite | SQLite | File-based storage for local development. Also supports an in-memory mode (Data Source=:memory:) for automated testing. |
This tutorial uses SQLite for simplicity. For production configuration and setup of each provider, see the Storage documentation.
Add an IdentityServer Config File
Section titled “Add an IdentityServer Config File”Create a Config.cs file at the project root with minimal in-memory configuration. This is enough to get IdentityServer running for local development. You can expand it later to add real clients and resources.
using Duende.IdentityServer.Models;
public static class Config{ public static IEnumerable<IdentityResource> IdentityResources => [ new IdentityResources.OpenId(), new IdentityResources.Profile(), new IdentityResources.Email(), ];
public static IEnumerable<ApiScope> ApiScopes => [ new ApiScope("api", "My API"), ];
public static IEnumerable<Client> Clients => [ new Client { ClientId = "interactive", ClientSecrets = { new Secret("secret".Sha256()) }, AllowedGrantTypes = GrantTypes.Code, RedirectUris = { "https://localhost:5002/signin-oidc" }, PostLogoutRedirectUris = { "https://localhost:5002/signout-callback-oidc" }, AllowedScopes = { "openid", "profile", "email", "api" }, }, ];}For a full explanation of clients, resources, and scopes, see the IdentityServer documentation.
Configure Services
Section titled “Configure Services”Open Program.cs and replace its contents with the following. The key call is .AddIdentityServer(...).AddUserManagement(...): User Management is registered as an extension on the IdentityServer builder, not as a separate top-level service.
The console sender prints OTP codes to the terminal, useful during local development without any mail infrastructure.
User Management does not include a console-based OTP dispatcher. Implement IOtpDispatcher to write codes to the console. Create ConsoleOtpDispatcher.cs:
using Duende.UserManagement.Authentication.Otp;
public class ConsoleOtpDispatcher : IOtpDispatcher{ public bool CanDispatch(OtpAddress address) => true;
public Task DispatchAsync(OtpAddress address, PlainTextOtp otp, TimeSpan expiresAfter, CancellationToken ct) { Console.WriteLine($"OTP for {address}: {otp.Text}"); return Task.CompletedTask; }}Then configure Program.cs:
using Duende.IdentityServer;using Duende.Storage.Schema;using Duende.Storage.Sqlite;using Duende.UserManagement;using Duende.UserManagement.Authentication.Otp;using Microsoft.AspNetCore.DataProtection;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services .AddIdentityServer(options => { options.UserInteraction.LoginUrl = "/Account/Login"; options.UserInteraction.LogoutUrl = "/Account/Logout"; options.Events.RaiseErrorEvents = true; options.Events.RaiseInformationEvents = true; options.Events.RaiseFailureEvents = true; options.Events.RaiseSuccessEvents = true; }) .AddInMemoryIdentityResources(Config.IdentityResources) .AddInMemoryApiScopes(Config.ApiScopes) .AddInMemoryClients(Config.Clients) .AddUserManagement(options => { options.AddSqliteStore(o => { o.ConnectionString = "Data Source=usermanagement.db"; }); });
builder.Services.AddSingleton<IOtpDispatcher, ConsoleOtpDispatcher>();
builder.Services.AddDataProtection() .SetApplicationName("OtpIdentityServerGettingStarted");
var app = builder.Build();
using (var scope = app.Services.CreateScope()){ await scope.ServiceProvider .GetRequiredService<IDatabaseSchema>() .MigrateAsync(CancellationToken.None);}
if (!app.Environment.IsDevelopment()){ app.UseExceptionHandler("/Error"); app.UseHsts();}
app.UseHttpsRedirection();app.UseRouting();app.UseIdentityServer();app.UseAuthorization();app.MapStaticAssets();app.MapRazorPages().WithStaticAssets();
app.Run();The SMTP sender delivers OTP codes by email. Bind the Smtp configuration section to the sender options:
using Duende.IdentityServer;using Duende.Storage.Schema;using Duende.Storage.Sqlite;using Duende.UserManagement;using Duende.UserManagement.Authentication;using Microsoft.AspNetCore.DataProtection;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services .AddIdentityServer(options => { options.UserInteraction.LoginUrl = "/Account/Login"; options.UserInteraction.LogoutUrl = "/Account/Logout"; options.Events.RaiseErrorEvents = true; options.Events.RaiseInformationEvents = true; options.Events.RaiseFailureEvents = true; options.Events.RaiseSuccessEvents = true; }) .AddInMemoryIdentityResources(Config.IdentityResources) .AddInMemoryApiScopes(Config.ApiScopes) .AddInMemoryClients(Config.Clients) .AddUserManagement(options => { options.Authentication(configure => { configure.UseSmtpOtpDispatcher(x => builder.Configuration.GetSection("Smtp").Bind(x)); }); options.AddSqliteStore(o => { o.ConnectionString = "Data Source=usermanagement.db"; }); });
builder.Services.AddDataProtection() .SetApplicationName("OtpIdentityServerGettingStarted");
var app = builder.Build();
using (var scope = app.Services.CreateScope()){ await scope.ServiceProvider .GetRequiredService<IDatabaseSchema>() .MigrateAsync(CancellationToken.None);}
if (!app.Environment.IsDevelopment()){ app.UseExceptionHandler("/Error"); app.UseHsts();}
app.UseHttpsRedirection();app.UseRouting();app.UseIdentityServer();app.UseAuthorization();app.MapStaticAssets();app.MapRazorPages().WithStaticAssets();
app.Run();Add the corresponding section to appsettings.json:
{ "Smtp": { "Host": "localhost", "Port": 1025, "FromEmail": "noreply@example.com", "FromName": "noreply" }}A few things to note about this setup:
- Storage is configured inside
AddUserManagementusingoptions.AddSqliteStore(...), not as a separate top-level call. app.UseIdentityServer()replaces the standaloneapp.UseAuthentication()call. IdentityServer sets up authentication for you.- The database migration runs at startup using
IDatabaseSchema.MigrateAsync. This creates the SQLite file and schema on first run.
Phase 2: Build the OTP Login Pages
Section titled “Phase 2: Build the OTP Login Pages”With IdentityServer running, you now need the Razor Pages that handle the actual login flow. IdentityServer will redirect unauthenticated users to /Account/Login (as configured in options.UserInteraction.LoginUrl), so that is where you start.
Add a Login Page
Section titled “Add a Login Page”-
Create the
Pages/Account/directory, then createPages/Account/Login.cshtmlwith an email input form:Pages/Account/Login.cshtml @page@model OtpIdentityServer.Pages.Account.LoginModel@{ViewData["Title"] = "Log in";}<h2>Log in</h2><div asp-validation-summary="ModelOnly" class="validation-summary-errors"></div><form method="post"><div><label asp-for="Input.Email">Email address</label><input asp-for="Input.Email" type="email" required autofocus autocomplete="email" /><span asp-validation-for="Input.Email"></span></div><button type="submit">Send one-time password</button></form> -
Create the page model in
Pages/Account/Login.cshtml.cs:Pages/Account/Login.cshtml.cs using System.ComponentModel.DataAnnotations;using Duende.UserManagement;using Duende.UserManagement.Authentication.Otp;using Microsoft.AspNetCore.Mvc;using Microsoft.AspNetCore.Mvc.RazorPages;namespace OtpIdentityServer.Pages.Account;public class LoginModel(IOtpSender otpSender) : PageModel{[BindProperty]public InputModel Input { get; set; } = new();public class InputModel{[Required][EmailAddress]public string Email { get; set; } = string.Empty;}public void OnGet() { }public async Task<IActionResult> OnPostAsync(){if (!ModelState.IsValid){return Page();}if (!EmailAddress.TryCreate(Input.Email, out var email)){ModelState.AddModelError(nameof(Input.Email), "Invalid email format.");return Page();}var result = await otpSender.TrySendOtpAsync(new OtpAddress(OtpChannel.Email, email),HttpContext.RequestAborted);if (result is SendOtpResult.Sent sentResult){TempData["OtpToken"] = sentResult.Token.Value.ToString();return RedirectToPage("/Account/EnterOtp");}if (result is SendOtpResult.Blocked blocked){var blockedFor = blocked.SendingBlockedUntilUtc - DateTimeOffset.UtcNow;var blockedMessage = $"Too many attempts. Try again in {Math.Ceiling(blockedFor.TotalSeconds)} second(s).";ModelState.AddModelError(string.Empty, blockedMessage);return Page();}ModelState.AddModelError(string.Empty, "Failed to send one-time password.");return Page();}}
When the form is submitted, IOtpAuthenticator.TrySendOtpAsync sends a one-time password to the provided email address and returns a result containing the OTP token. If sending succeeds, the token is stored in TempData so the next page can verify the code the user enters. If sending is blocked due to rate limiting, a descriptive error is shown instead.
Add an OTP Verification Page
Section titled “Add an OTP Verification Page”-
Create
Pages/Account/EnterOtp.cshtml, the page where the user enters the one-time password they received by email:Pages/Account/EnterOtp.cshtml @page@model OtpIdentityServer.Pages.Account.EnterOtpModel<h2>Enter one-time password</h2>@if (!ViewData.ModelState.IsValid){<ul>@foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors)){<li>@error.ErrorMessage</li>}</ul>}<form method="post"><input type="hidden" asp-for="Input.Token" /><label asp-for="Input.Code">One-time password</label><input asp-for="Input.Code" type="text" autocomplete="one-time-code" autofocus /><button type="submit">Sign in</button></form> -
Create
Pages/Account/EnterOtp.cshtml.cs:Pages/Account/EnterOtp.cshtml.cs using System.ComponentModel.DataAnnotations;using System.Security.Claims;using Duende.IdentityServer.Services;using Duende.UserManagement.Authentication.Otp;using Microsoft.AspNetCore.Authentication;using Microsoft.AspNetCore.Mvc;using Microsoft.AspNetCore.Mvc.RazorPages;namespace OtpIdentityServer.Pages.Account;public class EnterOtpModel(IOtpAuthenticator otpAuthenticator,IIdentityServerInteractionService interaction) : PageModel{[BindProperty]public InputModel Input { get; set; } = new();public class InputModel{[Required]public string Token { get; set; } = string.Empty;[Required]public string Code { get; set; } = string.Empty;}public IActionResult OnGet(){var token = TempData["OtpToken"]?.ToString();if (token is null){return RedirectToPage("/Account/Login");}Input.Token = token;return Page();}public async Task<IActionResult> OnPostAsync(string? returnUrl){if (!ModelState.IsValid){return Page();}if (string.IsNullOrWhiteSpace(Input.Code) || string.IsNullOrWhiteSpace(Input.Token)){ModelState.AddModelError(string.Empty, "Invalid input.");return Page();}var otp = PlainTextOtp.Create(Input.Code);var token = OtpToken.Create(Input.Token);var authResult = await otpAuthenticator.TryAuthenticateAsync(otp, token, HttpContext.RequestAborted);if (authResult is not OtpAuthenticationResult.Success otpSuccess){ModelState.AddModelError(string.Empty, "Invalid or expired code. Please try again.");return Page();}var claims = new List<Claim>{new("sub", otpSuccess.UserSubjectId.ToString()!),new(ClaimTypes.Name, otpSuccess.Address.SubjectId.ToString()!),};var identity = new ClaimsIdentity(claims, "otp");var principal = new ClaimsPrincipal(identity);await HttpContext.SignInAsync(Duende.IdentityServer.IdentityServerConstants.DefaultCookieAuthenticationScheme,principal,new AuthenticationProperties());returnUrl = interaction.IsValidReturnUrl(returnUrl) ? returnUrl : "~/";return LocalRedirect(returnUrl!);}}
OnGet reads the OTP token that the Login page stored in TempData["OtpToken"] and copies it into the hidden form field. If the token is missing (for example, the user navigated here directly), the page redirects back to /Account/Login.
OnPostAsync parses the submitted token and code into their strongly-typed counterparts (OtpToken and PlainTextOtp), then calls IOtpAuthenticator.TryAuthenticateAsync. The method returns an OtpAuthenticationResult, which is a discriminated union: pattern-match on OtpAuthenticationResult.Success to continue, or show an error for any other result.
On success, the page signs the user in using IdentityServer’s default cookie scheme and redirects to the returnUrl provided by IdentityServer (validated with IIdentityServerInteractionService).
Add a Logout Page
Section titled “Add a Logout Page”-
Create
Pages/Account/Logout.cshtml:Pages/Account/Logout.cshtml @page@model OtpIdentityServer.Pages.Account.LogoutModel -
Create
Pages/Account/Logout.cshtml.cs:Pages/Account/Logout.cshtml.cs using Duende.IdentityServer.Services;using Microsoft.AspNetCore.Authentication;using Microsoft.AspNetCore.Mvc;using Microsoft.AspNetCore.Mvc.RazorPages;namespace OtpIdentityServer.Pages.Account;public class LogoutModel(IIdentityServerInteractionService interaction) : PageModel{public async Task<IActionResult> OnPostAsync(string? logoutId){var context = await interaction.GetLogoutContextAsync(logoutId, HttpContext.RequestAborted);await HttpContext.SignOutAsync(Duende.IdentityServer.IdentityServerConstants.DefaultCookieAuthenticationScheme);var postLogoutRedirect = context?.PostLogoutRedirectUri;if (!string.IsNullOrEmpty(postLogoutRedirect)){return Redirect(postLogoutRedirect);}return RedirectToPage("/Account/Login");}}
OnPostAsync signs the user out of IdentityServer’s cookie scheme and then redirects to the post-logout URI provided by the client application, or back to /Account/Login if none is set. Using POST (rather than GET) prevents cross-site request forgery attacks that could silently sign a user out by embedding a link.
Protect the Home Page
Section titled “Protect the Home Page”-
Update
Pages/Index.cshtml.csto require an authenticated user by adding the[Authorize]attribute:Pages/Index.cshtml.cs using Microsoft.AspNetCore.Authorization;using Microsoft.AspNetCore.Mvc.RazorPages;namespace OtpIdentityServer.Pages;[Authorize]public class IndexModel : PageModel{public void OnGet(){}} -
Update
Pages/Index.cshtmlto greet the signed-in user and provide a sign-out button:Pages/Index.cshtml @page@model OtpIdentityServer.Pages.IndexModel@{ViewData["Title"] = "Home";}<h2>Welcome, @User.Identity!.Name!</h2><form method="post" asp-page="/Account/Logout"><button type="submit">Sign out</button></form>
User.Identity!.Name contains the email address that was set as ClaimTypes.Name during OTP verification. Because IdentityServer is configured with LoginUrl = "/Account/Login", any unauthenticated request to / is automatically redirected to the login page.
Run and Test
Section titled “Run and Test”-
Start the application:
Terminal dotnet run --launch-profile https -
Open your browser and navigate to
https://localhost:7083(or the URL shown in the terminal). -
You are redirected to
/Account/Login. Enter your email address and click Send one-time password. -
Retrieve the OTP code:
The OTP code is printed directly to the terminal. Copy it from there.
Check your inbox for the email containing the OTP code. If you are using Mailpit locally, open
http://localhost:8025to find the message. -
Enter the code on the
/Account/EnterOtppage and click Sign in. -
You land on the home page showing a welcome message with the email address of the user.
-
Click Sign out to return to the login page.
Congratulations! You now have a working ASP.NET Core application with IdentityServer and OTP-based authentication powered by Duende User Management. Users can sign in with their email address, verify a one-time code, and access protected pages. New users are automatically registered on their first successful login.