We are going to extend the code based on the solution from the previous article. Obviously, you can follow along by downloading the whole project from my GitHub. This solution in based on ASP.NET Core 8.
Preparing basic sign-in mechanism
First of all, let’s create a very basic login form (Login.cshtml). It sends POST requests to Login action on AccountController. The view’s model is of the type LoginViewModel and contains UserName, Password, and ReturnUrl properties.
@model MultitenantWebApp.LoginViewModel
<div>Please login</div>
<form asp-controller="Account" asp-action="Login" method="post"> <input type="hidden" asp-for="ReturnUrl"/> <div> Login: <input type="text" asp-for="UserName"/> </div> <div> Password: <input type="password" asp-for="Password"/> </div> <input type="submit" value="Login"/></form>public class LoginViewModel{ public string UserName { get; set; } public string Password { get; set; } public string ReturnUrl { get; set; }}Now we need to implement AccountController. It has Login action that can be invoked with GET or POST requests. GET Login returns Login view, POST Login creates ClaimsPrincipal object based on ViewModel. For simplicity, there is no user’s credentials validation mechanism. In other words, all usernames and passwords are valid (very secure 😄!). This is the place where you can add your own validation logic.
After successfully signing in, the application redirects the user to the tenant base URL. Unfortunately Redirect("/") won’t cooperate with PathBase which is set in TenantResolutionMiddleware. That’s one of the drawbacks of this resolution strategy. There’s also Logout a method that deletes a user’s cookie.
public class AccountController(ITenantContext tenantContext) : Controller{ [HttpGet] public ActionResult Login([FromQuery] string returnUrl) { return View(new LoginViewModel { ReturnUrl = returnUrl }); }
[HttpPost] [AutoValidateAntiforgeryToken] public async Task<ActionResult> Login([FromForm] LoginViewModel viewModel) { var claims = new List<Claim> { new("UserName", viewModel.UserName) };
var identity = new ClaimsIdentity( claims, CookieAuthenticationDefaults.AuthenticationScheme);
var claimsPrincipal = new ClaimsPrincipal(identity);
await HttpContext.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, claimsPrincipal);
return Redirect(viewModel.ReturnUrl ?? $"/{tenantContext.CurrentTenant.Name}"); }
[HttpGet] public async Task<ActionResult> Logout() { await HttpContext.SignOutAsync(); return RedirectToAction("Login"); }}In HomeController we also need to modify Index method so it returns logged user’s information. Note that this controller is also decorated with AuthorizeAttribute. As a result, only users that have signed in are able to invoke actions here.
[Authorize]public class HomeController(ITenantContext tenantContext) : Controller{ [HttpGet] public ActionResult Index() { var loggedUserName = User.Claims.Single(x => x.Type == "UserName").Value; var tenantName = tenantContext.CurrentTenant.Name;
return View(new HomeViewModel { Message = $"User '{loggedUserName}' is logged on tenant '{tenantName}'" }); }}
public class HomeViewModel{ public required string Message { get; init; }}Resolving cookie options
In order to make it work correctly and securely, we need to resolve tenant-specific cookie options per request. In this scenario, it will be a cookie name and data protection, provider. The last one is needed to hold the user back from decrypting another tenant's cookie.
Our flow works like this:
- Authentication handler requests
CookieAuthenticationOptionsobject. For that purpose it usesIOptionMonitor. IOptionMonitorinvokesCreatemethod onIOptionsFactory.IOptionsFactorycreates options object and after that invokesConfiguremethod onIConfigureNamedOptions.IConfigureNamedOptionssets tenant specific options.
In this solution, we are going to register our own implementation of IOptionsMonitor and IConfigureNamedOptions.

Let’s start from the end – IConfigureNamedOptions. It takes IHttpContextAccessor and enigmatic Action in a constructor. This action is a delegate that will be set on the dependency container level. In Configure method, apart from validating some stuff, we just call injected Action. There is also an overload for Configure method which is required by IConfigureNamedOptions interface. Options factory won’t use it so for simplicity it just delegates to the appropriate Configure method with Options.DefaultName which is an empty string. You can see the source code of OptionsFactory here.
public class CookieConfigureNamedOptions( IHttpContextAccessor httpContextAccessor, Action<CookieAuthenticationOptions, HttpContext> configureAction) : IConfigureNamedOptions<CookieAuthenticationOptions>{ public void Configure(string name, CookieAuthenticationOptions options) { if (!string.Equals(name, CookieAuthenticationDefaults.AuthenticationScheme, StringComparison.Ordinal)) { return; }
var httpContext = httpContextAccessor?.HttpContext;
if (httpContext == null) { throw new InvalidOperationException("HttpContext is not available."); }
configureAction(options, httpContext); }
public void Configure(CookieAuthenticationOptions options) => Configure(Options.DefaultName, options);}Now it’s time to implement IOptionsMonitor. The only responsibility of this is to call Create method on IOptionsFactory. We are going to register it as a singleton, so you can potentially add some kind of cache here (with keys per tenant). Because options instance is lightweight, we are not going to do this here, but feel free to adjust it to your needs.
The reason why we register our own implementation of IOptionsMonitor is that we need to bypass the cache provided by the default implementation (see source code). It holds CookieAuthenticationOptions under the Cookies key, so every tenant would get the same configuration.
public class CookieOptionsMonitor( IOptionsFactory<CookieAuthenticationOptions> optionsFactory) : IOptionsMonitor<CookieAuthenticationOptions>{ public CookieAuthenticationOptions CurrentValue => Get(Options.DefaultName);
public CookieAuthenticationOptions Get(string name) { return optionsFactory.Create(name); }
public IDisposable OnChange(Action<CookieAuthenticationOptions, string> listener) => null;}Registering cookie authentication handler
In ServiceCollectionExtensions we can implement AddMultitenantAuthentication method which registers cookie authentication handler. This method also invokes AddPerRequestCookieOptions that contains registration of IOptionsMonitor and IConfigureOptions. As an argument, we provide a delegate that configures tenant-specific cookie options. It sets the cookie name and data protection provider.
As a result, tenant1 won’t be able to decrypt tenant2 cookie and create ClaimsPrincipal based on that. It’s also worth mentioning that purpose, which is an argument of CreateProtector, should be unique throughout your application. Note that if your app is hosted as multiple instances, it needs to synchronize encryption keys! In case you host it on Azure App Service it works out of the box. You can read more about it in these articles:
- Share authentication cookies among ASP.NET apps
- Data Protection key management and lifetime in ASP.NET Core
public static class ServiceCollectionExtensions{ // ...
public static IServiceCollection AddMultitenantAuthentication(this IServiceCollection services) { services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; }).AddCookie();
services.AddPerRequestCookieOptions((options, httpContext) => { var tenant = httpContext .RequestServices.GetRequiredService<ITenantContext>() .CurrentTenant;
options.DataProtectionProvider = httpContext .RequestServices.GetRequiredService<IDataProtectionProvider>() .CreateProtector($"App.Tenants.{tenant.Name}");
options.Cookie.Name = $"{tenant.Name}-Cookie"; });
return services; }
private static void AddPerRequestCookieOptions( this IServiceCollection services, Action<CookieAuthenticationOptions, HttpContext> configAction) { services.AddSingleton<IOptionsMonitor<CookieAuthenticationOptions>, CookieOptionsMonitor>(); services.AddSingleton<IConfigureOptions<CookieAuthenticationOptions>>(provider => { var httpContextAccessor = provider.GetRequiredService<IHttpContextAccessor>(); return new CookieConfigureNamedOptions(httpContextAccessor, configAction); }); }}Finally, to make it work we extend HTTP pipeline (in Program.cs) with authentication and authorization middlewares. It needs to be before MapControllerRoute method.
var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
services.AddHttpContextAccessor();services.AddControllersWithViews();services.AddMultitenancy();services.AddMultitenantAuthentication();
var app = builder.Build();
app.UseDeveloperExceptionPage();app.UseStaticFiles();app.UseMiddleware<TenantResolutionMiddleware>();app.UseRouting();app.UseAuthentication();app.UseAuthorization();
app.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}");
app.Run();Time for testing
Ok, let’s run the application. Open a browser and sign in to tenant1. After that, we are redirected to the home page with the appropriate message.

When we switch to tenant2 we are no longer authenticated.

Let’s log in as tenant2User. We’ve successfully signed in.

When we switch to tenant1 we are still authenticated but as a different user.

Ok, let’s try to hack it 🐱💻! I’ve signed out from tenant2 and switched to tenant1. In developer tools, I changed the name of identity cookie to tenant2-Cookie and path to /tenant2. Then I opened the tenant2 route and as you can see I’m not authenticated. Application is not able to decrypt this cookie and create ClaimsPrincipal due to the data protection mechanism.

You can download the source code from my GitHub.
Conclusion
To sum up, we’ve created a mechanism that allows registering tenant-specific cookie options. Thanks to that users can use our application being signed in on different tenants. There are of course many solutions to solve this problem. The main purpose of this one is to be simple and effective. You can potentially extend it and adjust it to your needs.
