How to Implement Passwordless Authentication in ASP.NET Core
By FoxLearn 3/3/2025 8:05:26 AM 141
In this guide, we’ll demonstrate how to implement passwordless authentication in an ASP.NET Core Web API using ASP.NET Core Identity and JWT.
Getting Started with the Project
We'll begin by creating a new ASP.NET Core Web API project, utilizing the latest version of ASP.NET Core. For simplicity, we’ll focus on the backend and use Postman to interact with the API.
Next, install the necessary packages by running the following commands in your console:
Install-Package Microsoft.AspNetCore.Identity Install-Package Microsoft.AspNetCore.Authentication.JwtBearer Install-Package Microsoft.EntityFrameworkCore Install-Package Microsoft.EntityFrameworkCore.SqlServer
These packages are essential for integrating ASP.NET Core Identity and JWT Bearer Authentication into your application.
Next, update the appsettings.json
configuration file:
{ "AllowedHosts": "*", "ConnectionStrings": { "loginAppConnection": "Server=.;Initial Catalog=db;Integrated Security=true" }, "JWT": { "Key": "Secret key for jwt", "Issuer": "https://foxlearn.com", "Audience": "foxlearn.com" } }
Here, you’ll add your database connection string and JWT configuration, which will be used later in the project.
Creating the Login Endpoint
Let’s now create a controller to handle user authentication. We’ll call it AccountController
inside the Controllers folder:
private readonly UserManager<IdentityUser> _userManager; public AccountController(UserManager<IdentityUser> userManager) { _userManager = userManager; }
By injecting the UserManager<IdentityUser>
service into the controller, we can easily manage user identities.
First, let’s implement the GET endpoint for login:
[HttpGet] public IActionResult Login(string returnURL) { return Ok(new { Message = "Unrecognized user. You must sign in to use this weather service.", LoginUrl = Url.ActionLink(action: "", controller: "Account", values: new { ReturnURL = returnURL }, protocol: Request.Scheme), Schema = "{ \n userName * string \n email * string($email) \n }" }); }
This endpoint provides a response with a login message, login URL, and the expected schema for login (username and email).
Next, let’s create the POST endpoint for processing the login:
[HttpPost] public async Task<IActionResult> Login(LoginModel model) { var user = await _userManager.FindByEmailAsync(model.Email); var returnUrl = HttpContext?.Request.Query.FirstOrDefault(r => r.Key == "returnUrl"); if(user == null) { return Unauthorized(); } else { // Authentication logic here... } }
This endpoint should accept a username and email as input, and then attempt to authenticate the user. If the user is valid, we will proceed with the login and redirect them to the provided returnUrl. If the authentication fails, a 403 access denied response will be returned.
Now, let’s create the LoginModel
class:
public class LoginModel { public string? UserName { get; set; } [Required] [EmailAddress] public string? Email { get; set; } }
This simple model contains an optional username and a required email address for user authentication.
Understanding How Passwordless Authentication Works
The goal is to authenticate users who visit the WeatherForecast endpoint. If the user isn’t authenticated, they will be redirected to the login page to provide their email and username.
- User arrives at the WeatherForecast endpoint: If they aren’t authenticated, they are redirected to the login page.
- Login Page: Users are asked for their username and email. Once submitted, the server generates an authentication token.
- Email Link: The server sends a link to the user’s email address containing the token.
- Redirection to App: The user clicks on the link and is authenticated, after which a session token is issued to them.
Implementing Passwordless Authentication
Let’s now add the logic to the Login POST method:
var token = userManager.GenerateUserTokenAsync(user, "Default", "passwordless-auth"); var url = Url.ActionLink(action: "", controller: "LoginRedirect", values: new { Token = token.Result, Email = model.Email, ReturnUrl = returnUrl?.Value }, protocol: Request.Scheme); return Ok(url);
In this code, we generate an authentication token for the user and return a link containing the token, email, and return URL.
Creating the LoginRedirect Controller
Next, we need to create the LoginRedirectController
to handle the redirection and token verification:
private readonly UserManager<IdentityUser> _userManager; private readonly IConfiguration _iconfiguration; public LoginRedirectController(UserManager<IdentityUser> userManager, IConfiguration iconfiguration) { _userManager = userManager; _iconfiguration = iconfiguration; }
This controller has a GET endpoint that handles the user authentication:
[HttpGet] public async Task<IActionResult> Login(string token, string email, string returnUrl) { var user = await _userManager.FindByEmailAsync(email); var isValid = await _userManager.VerifyUserTokenAsync(user, "Default", "passwordless-auth", token); if (isValid) { await _userManager.UpdateSecurityStampAsync(user); await HttpContext.SignInAsync( IdentityConstants.ApplicationScheme, new ClaimsPrincipal( new ClaimsIdentity( new List<Claim> { new Claim("sub", user.Id) }, IdentityConstants.ApplicationScheme ) ) ); return new RedirectResult($"~{returnUrl}"); } return Unauthorized(); }
This endpoint verifies the authentication token and signs in the user if the token is valid. The user is then redirected to their original destination (return URL).
Implementing JWT Authentication
For non-browser clients (e.g., Postman), we’ll generate a JWT token after authentication:
var tokenHandler = new JwtSecurityTokenHandler(); var tokenKey = Encoding.UTF8.GetBytes(_iconfiguration["JWT:Key"]); var tokenDescriptor = new SecurityTokenDescriptor { Subject = new ClaimsIdentity(new Claim[] { new Claim(ClaimTypes.Email, email) }), Expires = DateTime.UtcNow.AddMinutes(10), SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(tokenKey), SecurityAlgorithms.HmacSha256Signature) }; var jwToken = tokenHandler.CreateToken(tokenDescriptor); return new OkObjectResult(new Tokens { Token = tokenHandler.WriteToken(jwToken) });
This generates a bearer token with a defined expiration time. If users are accessing the API through a tool like Postman, they will receive this bearer token, which is used to maintain their authenticated status throughout the session.
Let's quickly define the model for the token, which we'll call the Tokens
class:
public class Tokens { public string? Token { get; set; } public string? RefreshToken { get; set; } }
Now, let’s ensure that the WeatherForecastController is protected from unauthorized access by adding the [Authorize]
decorator:
[Authorize] [ApiController] [Route("[controller]")] public class WeatherForecastController : ControllerBase
You also need to configure authentication services in the Program.cs file:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { var key = Encoding.UTF8.GetBytes(Configuration["JWT:Key"]); options.SaveToken = true; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = false, ValidateAudience = false, ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(key) }; });
This will set up JWT authentication and allow the application to validate tokens and authenticate users properly.
Next, let’s set up the application cookies:
builder.Services.ConfigureApplicationCookie(options => { options.ExpireTimeSpan = TimeSpan.FromHours(1); options.SlidingExpiration = true; options.LoginPath = new PathString("/Account"); options.ReturnUrlParameter = "ReturnURL"; });
By configuring the application cookies, we set the session expiration to 1 hour and ensure that unauthenticated users are redirected to the /Account endpoint. The optional ReturnURL parameter helps future applications determine where to redirect the user after a successful login.
builder.Services.AddIdentity<IdentityUser, IdentityRole>() .AddEntityFrameworkStores<IdentityDbContext>() .AddDefaultTokenProviders(); builder.Services.AddDbContext<LoginContext>(options => options.UseSqlServer(Configuration.GetConnectionString("loginAppConnection"))); builder.Services.AddTransient<IdentityDbContext, LoginContext>(); builder.Services.AddSingleton(Configuration);
With the Identity service, we will manage application users, recognize usernames, email addresses, and any other user-specific information we choose to track. To store this data, we'll provide a database context and a configuration object that will validate our JWT using our secret key.
Next, let's configure the data storage for user Identity information. Since we are using EntityFramework along with the Identity library, the database schema is automatically created for us.
To initialize the database, we’ll create a DBInitializer
class with CreateDbIfNotExists
and Initialize
static methods:
private static async void CreateDbIfNotExists(WebApplication app) { using (var serviceScope = app.Services.CreateScope()) { var services = serviceScope.ServiceProvider; try { var identityUser = services.GetRequiredService<UserManager<IdentityUser>>(); var loginContext = services.GetRequiredService<LoginContext>(); loginContext.Database.EnsureCreated(); await Initialize(identityUser, loginContext); } catch (Exception ex) { var logger = services.GetRequiredService<ILogger<Program>>(); logger.LogError(ex, "An error occurred creating the DB."); } } }
In the CreateDbIfNotExists
method, we retrieve the UserManager
and LoginContext
from the service provider, ensure that the database is created, and then call the Initialize
method to populate the database with users.
Next, we define the Initialize
method:
public static async Task Initialize(UserManager<IdentityUser> userManager, LoginContext context) { // Check if there are any users in the database if (context.Users.Any()) { return; // Database has already been seeded } var users = new IdentityUser[] { new IdentityUser {UserName = "user1", Email = "[email protected]"}, new IdentityUser {UserName = "user2", Email = "[email protected]"}, new IdentityUser {UserName = "user3", Email = "[email protected]"} }; foreach (var user in users) { await userManager.CreateAsync(user); } context.SaveChanges(); }
In the Initialize
method, we first check if there are any users in the database. If not, we seed the database with some sample users. This method will run on startup, but only in the development environment to avoid overwriting production data.
In the Program.cs
file, we will add a line inside the if (app.Environment.IsDevelopment())
block to ensure that the database is initialized only during development:
if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); DBInitializer.CreateDbIfNotExists(app); }
By adding it to the development environment block, we ensure that the database is initialized and seeded only in development, preventing accidental overwriting of production data.
Next, we’ll apply the necessary middleware configurations to handle authentication and authorization:
app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.MapControllerRoute( name: "Identity", pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}"); app.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");
The UseAuthentication()
and UseAuthorization()
methods ensure that authentication and authorization services are applied. The MapControllers()
and MapControllerRoute()
methods configure routing for Identity and the default Web API controllers.
When you make a request to the WeatherForecast endpoint in Postman, you’ll be redirected to the Account endpoint if you aren’t authenticated. After submitting your username and email, you’ll receive a login link with an authentication token. Once you click the link, you'll be signed in and able to access the protected WeatherForecast endpoint. By using email-based authentication and token verification, users can log in securely without needing to remember a password.
- How to securely reverse-proxy ASP.NET Core
- How to Retrieve Client IP in ASP.NET Core Behind a Reverse Proxy
- Only one parameter per action may be bound from body in ASP.NET Core
- The request matched multiple endpoints in ASP.NET Core
- How to Create a custom model validation attribute in ASP.NET Core
- How to disable ModelStateInvalidFilter in ASP.NET Core
- How to fix LoginPath not working in ASP.NET Core
- Synchronous operations are disallowed