Introduction
In web development, Single Page Applications (SPAs) have gained immense popularity due to their seamless user experience and enhanced performance. SPAs load a single HTML page and dynamically update content as the user interacts with the application. However, with the increasing complexity of SPAs, ensuring security has become a paramount concern.
This tutorial will guide you through building a secure SPA using C# and Blazor, Microsoft’s open-source framework for creating interactive web UIs with .NET. We will cover key security aspects such as authentication, authorization, data protection, and secure communication. This tutorial is intended for developers who have a basic understanding of Blazor and .NET but are looking to deepen their knowledge in securing SPAs.
Prerequisites
Before we start, make sure you have the following tools and knowledge:
- Visual Studio 2019/2022: You can download it from Visual Studio’s official website.
- .NET 5 or later: Ensure you have the latest version installed. Download from here.
- Basic understanding of Blazor: Familiarity with creating Blazor components and basic routing.
- Knowledge of C#: Understanding of C# programming language.
- Basic understanding of web security concepts: Familiarity with terms like authentication, authorization, CSRF, XSS, etc.
Setting Up the Project
Step 1: Create a Blazor WebAssembly Project
Open Visual Studio and create a new project. Select “Blazor WebAssembly App” and click “Next.” Name your project “SecureBlazorSPA” and choose a location to save it. Ensure the “ASP.NET Core hosted” checkbox is selected. This setting creates a solution with three projects: a Blazor WebAssembly project, an ASP.NET Core server project, and a shared project for shared code.
Step 2: Configure HTTPS
To ensure secure communication, make sure your application uses HTTPS. Visual Studio sets this up by default, but it’s good to verify.
- Open the
launchSettings.json
file in theServer
project. - Ensure the
https
profile is configured correctly. It should look something like this:
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:5001;http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
Code language: JSON / JSON with Comments (json)
Step 3: Configure Authentication and Authorization
For this tutorial, we’ll use ASP.NET Core Identity for authentication and authorization. ASP.NET Core Identity provides a framework for managing user accounts, roles, and more.
Install the Required NuGet Packages
In the Server
project, install the following NuGet packages:
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.IdentityModel.Tokens
dotnet add package System.IdentityModel.Tokens.Jwt
Code language: C# (cs)
Set Up Identity
Open Startup.cs
in the Server
project and add the following using statements:
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using SecureBlazorSPA.Server.Data;
using SecureBlazorSPA.Server.Models;
using System.Text;
Code language: C# (cs)
Configure services in the ConfigureServices
method:
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
services.AddDefaultIdentity<ApplicationUser>(options => options.SignIn.RequireConfirmedAccount = true)
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>();
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = Configuration["Jwt:Issuer"],
ValidAudience = Configuration["Jwt:Issuer"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["Jwt:Key"]))
};
});
services.AddControllersWithViews();
services.AddRazorPages();
}
Code language: C# (cs)
In the Configure
method, ensure you add authentication and authorization middleware:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseWebAssemblyDebugging();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapRazorPages();
endpoints.MapControllers();
endpoints.MapFallbackToFile("index.html");
});
}
Code language: C# (cs)
Add JWT settings to your appsettings.json
:
"Jwt": {
"Key": "YourSecretKey12345678",
"Issuer": "https://localhost:5001"
}
Code language: JSON / JSON with Comments (json)
Create the Identity Models
In the Server
project, create a Models
folder.
Create a class ApplicationUser.cs
that extends IdentityUser
:
using Microsoft.AspNetCore.Identity;
namespace SecureBlazorSPA.Server.Models
{
public class ApplicationUser : IdentityUser
{
}
}
Code language: C# (cs)
Create a Data
folder and add ApplicationDbContext.cs
:
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using SecureBlazorSPA.Server.Models;
namespace SecureBlazorSPA.Server.Data
{
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
}
}
Code language: C# (cs)
Update the ApplicationDbContext
in Startup.cs
:
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
Code language: C# (cs)
Update appsettings.json
with your connection string:
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=aspnet-SecureBlazorSPA;Trusted_Connection=True;MultipleActiveResultSets=true"
}
Code language: JSON / JSON with Comments (json)
Run the following commands to create and apply the initial migration:
dotnet ef migrations add InitialCreate --project SecureBlazorSPA.Server
dotnet ef database update --project SecureBlazorSPA.Server
Code language: Bash (bash)
Step 4: Create a Token Service
In the Server
project, create a Services
folder and add ITokenService.cs
:
using System.Threading.Tasks;
namespace SecureBlazorSPA.Server.Services
{
public interface ITokenService
{
Task<string> GenerateToken(ApplicationUser user);
}
}
Code language: C# (cs)
Implement the interface in TokenService.cs
:
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using SecureBlazorSPA.Server.Models;
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
namespace SecureBlazorSPA.Server.Services
{
public class TokenService : ITokenService
{
private readonly IConfiguration _configuration;
public TokenService(IConfiguration configuration)
{
_configuration = configuration;
}
public async Task<string> GenerateToken(ApplicationUser user)
{
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.UserName),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Key"]));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _configuration["Jwt:Issuer"],
audience: _configuration["Jwt:Issuer"],
claims: claims,
expires: DateTime.Now.AddMinutes(30),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
}
Code language: C# (cs)
Register the TokenService
in Startup.cs
:
services.AddTransient<ITokenService, TokenService>();
Code language: C# (cs)
Step 5: Create Account Controllers
Create an AccountController
in the Controllers
folder of the Server
project:
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using SecureBlazorSPA.Server.Models;
using SecureBlazorSPA.Server.Services;
using System.Threading.Tasks;
namespace SecureBlazorSPA.Server.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class AccountController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly ITokenService _tokenService;
public AccountController(UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager, ITokenService tokenService)
{
_userManager = userManager;
_signInManager = signInManager;
_tokenService = tokenService;
}
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var user = new ApplicationUser { UserName = model.Email, Email = model.Email };
var result = await _userManager.CreateAsync(user, model.Password);
if (result.Succeeded)
{
await _signInManager.SignInAsync(user, false);
return Ok(new { Token = await _tokenService.GenerateToken(user) });
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
return BadRequest(ModelState);
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, false);
if (result.Succeeded)
{
var user = await _userManager.FindByEmailAsync(model.Email);
return Ok(new { Token = await _tokenService.GenerateToken(user) });
}
return Unauthorized();
}
}
}
Code language: C# (cs)
Create RegisterModel.cs
and LoginModel.cs
in the Models
folder:
public class RegisterModel
{
public string Email { get; set; }
public string Password { get; set; }
public string ConfirmPassword { get; set; }
}
public class LoginModel
{
public string Email { get; set; }
public string Password { get; set; }
public bool RememberMe { get; set; }
}
Code language: C# (cs)
Implementing Security in the Blazor Client
Step 6: Set Up Authentication State Provider
In the Client
project, create a Services
folder and add CustomAuthStateProvider.cs
:
using Microsoft.AspNetCore.Components.Authorization;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
namespace SecureBlazorSPA.Client.Services
{
public class CustomAuthStateProvider : AuthenticationStateProvider
{
private readonly HttpClient _httpClient;
public CustomAuthStateProvider(HttpClient httpClient)
{
_httpClient = httpClient;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var token = await _httpClient.GetStringAsync("token");
if (string.IsNullOrWhiteSpace(token))
{
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
var claims = ParseClaimsFromJwt(token);
var identity = new ClaimsIdentity(claims, "jwt");
var user = new ClaimsPrincipal(identity);
return new AuthenticationState(user);
}
public void NotifyUserAuthentication(string token)
{
var authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(ParseClaimsFromJwt(token), "jwt"));
var authState = Task.FromResult(new AuthenticationState(authenticatedUser));
NotifyAuthenticationStateChanged(authState);
}
public void NotifyUserLogout()
{
var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity());
var authState = Task.FromResult(new AuthenticationState(anonymousUser));
NotifyAuthenticationStateChanged(authState);
}
private IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
{
var payload = jwt.Split('.')[1];
var jsonBytes = ParseBase64WithoutPadding(payload);
var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);
return keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString()));
}
private byte[] ParseBase64WithoutPadding(string base64)
{
switch (base64.Length % 4)
{
case 2: base64 += "=="; break;
case 3: base64 += "="; break;
}
return Convert.FromBase64String(base64);
}
}
}
Code language: C# (cs)
Register the CustomAuthStateProvider
in Program.cs
:
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<CustomAuthStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(provider => provider.GetRequiredService<CustomAuthStateProvider>());
Code language: C# (cs)
Step 7: Create Authentication Components
Create a Pages
folder in the Client
project and add Login.razor
:
@page "/login"
@using Microsoft.AspNetCore.Components.Authorization
@inject HttpClient HttpClient
@inject CustomAuthStateProvider AuthStateProvider
@inject NavigationManager Navigation
<EditForm Model="loginModel" OnValidSubmit="HandleLogin">
<DataAnnotationsValidator />
<ValidationSummary />
<InputText @bind-Value="loginModel.Email" placeholder="Email" />
<InputText @bind-Value="loginModel.Password" type="password" placeholder="Password" />
<InputCheckbox @bind-Value="loginModel.RememberMe" /> Remember me
<button type="submit">Login</button>
</EditForm>
@code {
private LoginModel loginModel = new LoginModel();
private async Task HandleLogin()
{
var response = await HttpClient.PostAsJsonAsync("api/account/login", loginModel);
if (response.IsSuccessStatusCode)
{
var token = await response.Content.ReadAsStringAsync();
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "authToken", token);
AuthStateProvider.NotifyUserAuthentication(token);
Navigation.NavigateTo("/");
}
else
{
// Handle login failure
}
}
}
Code language: C# (cs)
Create Register.razor
:
@page "/register"
@using Microsoft.AspNetCore.Components.Authorization
@inject HttpClient HttpClient
@inject CustomAuthStateProvider AuthStateProvider
@inject NavigationManager Navigation
<EditForm Model="registerModel" OnValidSubmit="HandleRegister">
<DataAnnotationsValidator />
<ValidationSummary />
<InputText @bind-Value="registerModel.Email" placeholder="Email" />
<InputText @bind-Value="registerModel.Password" type="password" placeholder="Password" />
<InputText @bind-Value="registerModel.ConfirmPassword" type="password" placeholder="Confirm Password" />
<button type="submit">Register</button>
</EditForm>
@code {
private RegisterModel registerModel = new RegisterModel();
private async Task HandleRegister()
{
var response = await HttpClient.PostAsJsonAsync("api/account/register", registerModel);
if (response.IsSuccessStatusCode)
{
var token = await response.Content.ReadAsStringAsync();
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "authToken", token);
AuthStateProvider.NotifyUserAuthentication(token);
Navigation.NavigateTo("/");
}
else
{
// Handle registration failure
}
}
}
Code language: C# (cs)
Update App.razor
to handle authentication state:
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
<h1>Sorry, you're not authorized to view this page.</h1>
</NotAuthorized>
</AuthorizeRouteView>
</Found>
<NotFound>
<h1>Sorry, there's nothing at this address.</h1>
</NotFound>
</Router>
</CascadingAuthenticationState>
Code language: C# (cs)
Update MainLayout.razor
to add a logout button and display the user’s name if logged in:
@using Microsoft.AspNetCore.Components.Authorization
@inject CustomAuthStateProvider AuthStateProvider
@inject NavigationManager Navigation
<div class="top-row px-4 auth">
<a href="" @onclick="Logout">Logout</a>
<AuthorizeView>
<Authorized>
<span>Hello, @context.User.Identity.Name</span>
</Authorized>
<NotAuthorized>
<a href="login">Login</a>
<a href="register">Register</a>
</NotAuthorized>
</AuthorizeView>
</div>
@code {
private async Task Logout()
{
await JSRuntime.InvokeVoidAsync("localStorage.removeItem", "authToken");
AuthStateProvider.NotifyUserLogout();
Navigation.NavigateTo("/");
}
}
Code language: C# (cs)
Secure Communication
Step 8: Secure API Endpoints
Add the [Authorize]
attribute to secure your API endpoints. For example , in the WeatherForecastController
:
[Authorize]
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
// Your existing code
}
Code language: C# (cs)
Step 9: Handle CSRF Attacks
ASP.NET Core includes built-in protection against Cross-Site Request Forgery (CSRF) attacks. Make sure your forms include the anti-forgery token. In Blazor, you can use the AntiForgery
component.
Add the Microsoft.AspNetCore.Antiforgery
package to your project:
dotnet add package Microsoft.AspNetCore.Antiforgery
Code language: CSS (css)
Configure the service in Startup.cs
:
services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-TOKEN";
});
Code language: C# (cs)
Use the anti-forgery token in your forms. You can inject IAntiforgery
and use it in your pages or components:
@inject IAntiforgery Antiforgery
<EditForm Model="loginModel" OnValidSubmit="HandleLogin">
@Antiforgery.GetAndStoreTokens(HttpContext).RequestToken
<DataAnnotationsValidator />
<ValidationSummary />
<InputText @bind-Value="loginModel.Email" placeholder="Email" />
<InputText @bind-Value="loginModel.Password" type="password" placeholder="Password" />
<InputCheckbox @bind-Value="loginModel.RememberMe" /> Remember me
<button type="submit">Login</button>
</EditForm>
Code language: C# (cs)
Data Protection
Step 10: Encrypt Sensitive Data
ASP.NET Core provides data protection services for encrypting sensitive data. To use data protection, register it in Startup.cs
:
services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(@"\\server\share\directory\"))
.SetApplicationName("SecureBlazorSPA");
Code language: C# (cs)
Step 11: Use HTTPS
Ensure your entire application uses HTTPS. This can be enforced in the Startup.cs
:
app.UseHttpsRedirection();
Code language: C# (cs)
Conclusion
Building secure SPAs with C# and Blazor involves a multi-faceted approach that includes securing communication, managing authentication and authorization, and protecting sensitive data. This tutorial provided a comprehensive guide to implementing these security measures in a Blazor WebAssembly application.
By following these steps, you can create a robust and secure SPA that ensures the integrity, confidentiality, and availability of your application and user data. Remember to keep up with the latest security best practices and regularly update your dependencies to mitigate potential vulnerabilities.