Autenticación y autorización en Blazor: una guía práctica
Si ha trabajado con ASP.NET MVC o Razor Pages, probablemente tenga un modelo mental de cómo funciona la autenticación: el middleware intercepta la solicitud, verifica una cookie o token, completa HttpContext.User y listo. Blazor cambia ese modelo mental de maneras sutiles pero importantes, y si no comprende esas diferencias temprano, terminará depurando problemas de autenticación que parecen imposibles.
En esta publicación, quiero explicar cómo funcionan realmente la autenticación y la autorización en Blazor, cubriendo los modelos de alojamiento de servidor y WebAssembly. Iremos desde los fundamentos hasta los proveedores personalizados, OAuth externo y los obstáculos que he visto que hacen tropezar incluso a los desarrolladores .NET experimentados.
Por qué la autenticación en Blazor es diferente
En ASP.NET tradicional, cada interacción del usuario es una solicitud HTTP. El servidor valida las credenciales, establece una cookie y cada solicitud posterior transporta esa cookie. El proceso de autenticación es lineal y predecible.
Blazor Server funciona a través de una conexión SignalR persistente. Después de que la solicitud HTTP inicial carga la página, todas las interacciones posteriores se realizan a través de WebSockets. No hay una nueva solicitud HTTP por cada clic en un botón, por lo que el middleware no se vuelve a ejecutar en cada interacción. El HttpContext está disponible durante la conexión inicial, pero depender de él durante toda la vida útil de un circuito es una receta para errores.
Blazor WebAssembly se ejecuta completamente en el navegador. No hay ningún HttpContext del lado del servidor en absoluto. El estado de autenticación debe obtenerse de una API, almacenarse en el lado del cliente y administrarse mediante tokens, normalmente JWT. El servidor confía en el cliente sólo en lo que respecta a la validación del token.
Esto significa que Blazor necesita su propia abstracción para el estado de autenticación, una que funcione independientemente del modelo de alojamiento. Esa abstracción es el AuthenticationStateProvider.
Estado de autenticación: La Fundación
En el corazón del sistema de autenticación de Blazor se encuentra AuthenticationStateProvider. Esta es una clase abstracta que expone un único método crítico:
public abstract Task<AuthenticationState> GetAuthenticationStateAsync();
El objeto AuthenticationState envuelve un ClaimsPrincipal, el mismo modelo de identidad utilizado en todo .NET. Los componentes no se comunican directamente con cookies o tokens; preguntan al AuthenticationStateProvider el estado actual.
Para que este estado esté disponible para todo su árbol de componentes, Blazor proporciona CascadingAuthenticationState. Normalmente envuelves tu enrutador con él en App.razor o tu diseño:
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData"
DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
<p>You're not authorized to view this page.</p>
</NotAuthorized>
</AuthorizeRouteView>
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p>Page not found.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
El AuthorizeRouteView cumple una doble función aquí: verifica si el usuario está autenticado y autorizado antes de representar el componente de página coincidente, y proporciona una interfaz de usuario alternativa cuando no lo está.
En .NET 8 y versiones posteriores con el modelo Blazor unificado, configurará esto en su App.razor y el marco maneja el parámetro en cascada automáticamente cuando usa AddCascadingAuthenticationState() en su registro de servicio.
Integración de identidad ASP.NET
Para la mayoría de los proyectos, no es necesario crear la autenticación desde cero. ASP.NET Identity le brinda administración de usuarios, hash de contraseñas, autenticación de dos factores y confirmación de cuenta lista para usar.La configuración con Blazor comienza en Program.cs:
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddDefaultIdentity<IdentityUser>(options =>
{
options.SignIn.RequireConfirmedAccount = true;
options.Password.RequireDigit = true;
options.Password.RequiredLength = 8;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = IdentityConstants.ApplicationScheme;
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
});
Con la plantilla de Blazor Web App en .NET 8+, la interfaz de usuario de identidad con scaffolding utiliza componentes de Razor directamente. Obtiene páginas de inicio de sesión, registro y administración de cuentas que se integran naturalmente con el resto de su aplicación Blazor: no más mezclas incómodas de Razor Pages y componentes Blazor.
El ApplicationDbContext hereda de IdentityDbContext y necesitarás ejecutar migraciones para crear las tablas de identidad:
public class ApplicationDbContext : IdentityDbContext<IdentityUser>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options) { }
}
dotnet ef migrations add InitialIdentity
dotnet ef database update
El componente AuthorizeView
Una vez que el estado de autenticación fluye a través de su árbol de componentes, AuthorizeView le permite representar la interfaz de usuario de forma condicional:
<AuthorizeView>
<Authorized>
<p>Welcome, @context.User.Identity?.Name!</p>
<a href="/account/manage">Manage Account</a>
<form method="post" action="/account/logout">
<button type="submit">Log Out</button>
</form>
</Authorized>
<NotAuthorized>
<a href="/account/login">Log In</a>
<a href="/account/register">Register</a>
</NotAuthorized>
</AuthorizeView>
El parámetro context dentro de <Authorized> le brinda acceso a AuthenticationState, para que pueda inspeccionar reclamos, roles y la identidad del usuario directamente en su marcado.
También puedes usar AuthorizeView con roles y políticas:
<AuthorizeView Roles="Admin,Moderator">
<Authorized>
<button @onclick="DeletePost">Delete Post</button>
</Authorized>
</AuthorizeView>
<AuthorizeView Policy="CanEditArticles">
<Authorized>
<button @onclick="EditArticle">Edit</button>
</Authorized>
</AuthorizeView>
Una cosa a tener en cuenta: AuthorizeView es un problema de interfaz de usuario. Oculta o muestra elementos, pero no protege la lógica subyacente. Si alguien puede llamar a su punto final API o invocar su método directamente, omitirá AuthorizeView por completo. Aplique siempre la autorización también en el lado del servidor.
El atributo [Autorizar]
Para proteger una página completa, aplique el atributo [Authorize]:
@page "/admin/dashboard"
@attribute [Authorize(Roles = "Admin")]
<h1>Admin Dashboard</h1>
<p>Only administrators can see this page.</p>
Cuando un usuario no autenticado navega a esta página, AuthorizeRouteView se activa y representa la plantilla <NotAuthorized> que definiste anteriormente. Puedes redirigir a una página de inicio de sesión manejando el caso NotAuthorized con navegación:
<NotAuthorized>
@if (!context.User.Identity?.IsAuthenticated ?? true)
{
<RedirectToLogin />
}
else
{
<p>You don't have permission to access this page.</p>
}
</NotAuthorized>
Un componente RedirectToLogin simple podría verse así:
@inject NavigationManager Navigation
@code {
protected override void OnInitialized()
{
var returnUrl = Uri.EscapeDataString(Navigation.Uri);
Navigation.NavigateTo($"/account/login?returnUrl={returnUrl}", forceLoad: true);
}
}
El forceLoad: true es importante aquí: desea una navegación HTTP real para que el middleware de autenticación del lado del servidor pueda manejar el flujo de inicio de sesión correctamente.
Autorización basada en roles y políticas
Los roles son el modelo más simple: asigne usuarios a grupos como “Administrador” o “Editor” y luego verifique la membresía. Pero las políticas le brindan mucha más flexibilidad.
Registrar políticas en Program.cs:
builder.Services.AddAuthorizationCore(options =>
{
options.AddPolicy("CanPublish", policy =>
policy.RequireClaim("Permission", "Publish"));
options.AddPolicy("MinimumAge", policy =>
policy.Requirements.Add(new MinimumAgeRequirement(18)));
options.AddPolicy("PremiumUser", policy =>
policy.RequireRole("Premium")
.RequireClaim("Subscription", "Active"));
});
Los requisitos personalizados necesitan un controlador:
public class MinimumAgeRequirement : IAuthorizationRequirement
{
public int MinimumAge { get; }
public MinimumAgeRequirement(int minimumAge) => MinimumAge = minimumAge;
}
public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MinimumAgeRequirement requirement)
{
var dateOfBirthClaim = context.User.FindFirst("DateOfBirth");
if (dateOfBirthClaim is null)
return Task.CompletedTask;
var dateOfBirth = DateOnly.Parse(dateOfBirthClaim.Value);
var age = DateOnly.FromDateTime(DateTime.Today).Year - dateOfBirth.Year;
if (age >= requirement.MinimumAge)
context.Succeed(requirement);
return Task.CompletedTask;
}
}
Registre el controlador:
builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();
En los componentes, también puede verificar la autorización mediante programación cuando necesite lógica dinámica:
@inject IAuthorizationService AuthorizationService
@inject AuthenticationStateProvider AuthStateProvider
@code {
private bool canPublish;
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var result = await AuthorizationService.AuthorizeAsync(
authState.User, "CanPublish");
canPublish = result.Succeeded;
}
}
Proveedores externos de OAuth
Admitir “Iniciar sesión con Google” o “Iniciar sesión con GitHub” es sencillo con el middleware de autenticación ASP.NET. Estos se configuran en el lado del servidor, ya que el flujo de OAuth requiere redireccionamientos HTTP.
builder.Services.AddAuthentication()
.AddGoogle(options =>
{
options.ClientId = builder.Configuration["Auth:Google:ClientId"]!;
options.ClientSecret = builder.Configuration["Auth:Google:ClientSecret"]!;
options.Scope.Add("profile");
})
.AddMicrosoftAccount(options =>
{
options.ClientId = builder.Configuration["Auth:Microsoft:ClientId"]!;
options.ClientSecret = builder.Configuration["Auth:Microsoft:ClientSecret"]!;
})
.AddGitHub(options =>
{
options.ClientId = builder.Configuration["Auth:GitHub:ClientId"]!;
options.ClientSecret = builder.Configuration["Auth:GitHub:ClientSecret"]!;
});
Para GitHub, necesitará el paquete NuGet AspNet.Security.OAuth.GitHub, ya que no está incluido en las bibliotecas ASP.NET predeterminadas.
Luego, la interfaz de usuario de inicio de sesión proporciona enlaces que activan el desafío externo:
@page "/account/external-login"
<h2>Sign in with an external provider</h2>
<form method="post" action="/api/auth/external-login">
<button type="submit" name="provider" value="Google">
Sign in with Google
</button>
<button type="submit" name="provider" value="Microsoft">
Sign in with Microsoft
</button>
<button type="submit" name="provider" value="GitHub">
Sign in with GitHub
</button>
</form>
El punto final de API desencadena el desafío y maneja la devolución de llamada:
app.MapPost("/api/auth/external-login", (string provider, HttpContext context) =>
{
var properties = new AuthenticationProperties
{
RedirectUri = "/api/auth/external-callback"
};
return Results.Challenge(properties, [provider]);
});
La autenticación externa en Blazor siempre requiere una navegación de página completa; no se puede completar una redirección de OAuth dentro de un circuito SignalR o una aplicación WebAssembly sin pasar por el servidor.
Autenticación basada en tokens en Blazor WebAssemblyBlazor WebAssembly se ejecuta en el cliente, por lo que la autenticación basada en cookies no se aplica de la misma manera. En su lugar, normalmente se utilizan JWT almacenados en la memoria y adjuntos a las solicitudes HTTP salientes.
El marco proporciona AuthorizationMessageHandler para adjuntar tokens automáticamente:
builder.Services.AddHttpClient("API",
client => client.BaseAddress = new Uri("https://api.example.com"))
.AddHttpMessageHandler<AuthorizationMessageHandler>();
builder.Services.AddScoped(sp =>
sp.GetRequiredService<IHttpClientFactory>().CreateClient("API"));
Para aplicaciones Blazor WASM independientes que se autentican con su propia API, implementará un AuthenticationStateProvider personalizado que analiza el JWT:
public class JwtAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly ILocalStorageService _localStorage;
private readonly HttpClient _httpClient;
public JwtAuthenticationStateProvider(
ILocalStorageService localStorage,
HttpClient httpClient)
{
_localStorage = localStorage;
_httpClient = httpClient;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var token = await _localStorage.GetItemAsync<string>("authToken");
if (string.IsNullOrWhiteSpace(token))
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
var claims = ParseClaimsFromJwt(token);
var identity = new ClaimsIdentity(claims, "jwt");
return new AuthenticationState(new ClaimsPrincipal(identity));
}
public void NotifyAuthStateChanged()
{
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
private IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
{
var payload = jwt.Split('.')[1];
var padded = payload.Length % 4 switch
{
2 => payload + "==",
3 => payload + "=",
_ => payload
};
var bytes = Convert.FromBase64String(padded);
var json = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(bytes);
return json?.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString()))
?? Enumerable.Empty<Claim>();
}
}
Después de iniciar sesión correctamente, almacena el token y notifica el estado de autenticación:
public class AuthService
{
private readonly HttpClient _httpClient;
private readonly ILocalStorageService _localStorage;
private readonly JwtAuthenticationStateProvider _authStateProvider;
public AuthService(
HttpClient httpClient,
ILocalStorageService localStorage,
AuthenticationStateProvider authStateProvider)
{
_httpClient = httpClient;
_localStorage = localStorage;
_authStateProvider = (JwtAuthenticationStateProvider)authStateProvider;
}
public async Task<bool> LoginAsync(string email, string password)
{
var response = await _httpClient.PostAsJsonAsync("/api/auth/login",
new { Email = email, Password = password });
if (!response.IsSuccessStatusCode)
return false;
var result = await response.Content
.ReadFromJsonAsync<LoginResponse>();
await _localStorage.SetItemAsync("authToken", result!.Token);
_authStateProvider.NotifyAuthStateChanged();
return true;
}
public async Task LogoutAsync()
{
await _localStorage.RemoveItemAsync("authToken");
_authStateProvider.NotifyAuthStateChanged();
}
}
Una advertencia: almacenar JWT en localStorage los expone a ataques XSS. Para aplicaciones de mayor seguridad, considere mantener los tokens solo en la memoria y usar tokens de actualización, o adoptar el patrón Backend-for-Frontend (BFF) donde el servidor administra los tokens y el cliente usa cookies solo HTTP.
Blazor Server frente a WebAssembly: consideraciones de seguridad
El modelo de alojamiento cambia fundamentalmente su postura de seguridad.
Blazor Server mantiene toda la lógica de sus componentes en el servidor. El cliente solo ve diferencias HTML renderizadas en SignalR. Esto significa:
- La lógica sensible nunca abandona el servidor.
- Puede acceder a bases de datos y servicios internos directamente desde los componentes.
- El estado de autenticación proviene del
HttpContextdel servidor en la conexión inicial. - El circuito puede sobrevivir a la cookie de autenticación: si la cookie de un usuario caduca, el circuito permanece activo hasta que se desconecta.
- Debe manejar la desconexión del circuito con elegancia y volver a validar el estado de autenticación al volver a conectarse.
Blazor WebAssembly se ejecuta completamente en el navegador. Esto significa:
- Todo el código de su componente se puede descargar e inspeccionar.
- Nunca coloque secretos, cadenas de conexión o lógica empresarial confidencial en componentes WASM
- La autenticación solo se aplica al cliente para UX; la aplicación real debe ocurrir en su capa API
- La gestión de tokens es tu responsabilidad.
- Considere usar el modelo alojado donde un proyecto de servidor maneja la autenticación y sirve la aplicación WASM.
Un patrón que recomiendo para las aplicaciones WebAssembly es tratar cada componente como si fuera una “IU no confiable” y cada punto final de API como si lo llamara un cliente desconocido. Valide todo del lado del servidor independientemente de lo que verifique el cliente.
Creación de un proveedor de estado de autenticación personalizado
A veces, los proveedores integrados no se ajustan a su arquitectura. Tal vez se esté integrando con un sistema de autenticación heredado o necesite realizar una encuesta para detectar cambios en el estado de autenticación. Aquí hay un proveedor personalizado más completo para Blazor Server:
public class CustomAuthStateProvider : AuthenticationStateProvider
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IUserService _userService;
public CustomAuthStateProvider(
IHttpContextAccessor httpContextAccessor,
IUserService userService)
{
_httpContextAccessor = httpContextAccessor;
_userService = userService;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext?.User?.Identity?.IsAuthenticated != true)
return new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity()));
var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId is null)
return new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity()));
var user = await _userService.GetUserWithClaimsAsync(userId);
if (user is null)
return new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity()));
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id),
new(ClaimTypes.Name, user.DisplayName),
new(ClaimTypes.Email, user.Email)
};
claims.AddRange(user.Roles.Select(r => new Claim(ClaimTypes.Role, r)));
claims.AddRange(user.Permissions.Select(p => new Claim("Permission", p)));
var identity = new ClaimsIdentity(claims, "Custom");
return new AuthenticationState(new ClaimsPrincipal(identity));
}
public void MarkUserAsAuthenticated(string userId)
{
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
public void MarkUserAsLoggedOut()
{
var anonymous = new ClaimsPrincipal(new ClaimsIdentity());
var authState = Task.FromResult(new AuthenticationState(anonymous));
NotifyAuthenticationStateChanged(authState);
}
}
Regístrelo en Program.cs:
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>();
builder.Services.AddScoped<CustomAuthStateProvider>();
La idea clave aquí es NotifyAuthenticationStateChanged: llamarlo activa el parámetro en cascada para actualizarlo, lo que reevalúa cada AuthorizeView y AuthorizeRouteView en su árbol de componentes. Así es como hace que la interfaz de usuario reaccione a los eventos de inicio/cierre de sesión sin una actualización completa de la página.
Errores y soluciones comunes
Después de trabajar con la autenticación de Blazor en muchos proyectos, estos son los problemas que veo con más frecuencia:
1. Uso de HttpContext en los componentes del servidor BlazorHttpContext está disponible durante la solicitud HTTP inicial, pero está null o obsoleto durante las interacciones de SignalR. No inyecte IHttpContextAccessor en componentes que se ejecutan después del renderizado inicial.
Solución: Capture lo que necesita de HttpContext durante la inicialización y guárdelo en un servicio con alcance:
public class UserContext
{
public string? UserId { get; set; }
public string? AccessToken { get; set; }
}
// In a component that renders during the initial HTTP request:
@inject IHttpContextAccessor HttpContextAccessor
@inject UserContext UserContext
@code {
protected override void OnInitialized()
{
var context = HttpContextAccessor.HttpContext;
UserContext.UserId = context?.User.FindFirstValue(ClaimTypes.NameIdentifier);
UserContext.AccessToken = context?.Request.Headers.Authorization
.ToString().Replace("Bearer ", "");
}
}
2. El estado de autenticación no se actualiza después de iniciar sesión
Llamas a tu API de inicio de sesión, tiene éxito, pero la interfaz de usuario todavía muestra “Iniciar sesión”.
Solución: Debes llamar a NotifyAuthenticationStateChanged en tu AuthenticationStateProvider después de que cambie el estado de autenticación. El marco no detecta mágicamente que se almacenó un token o se configuró una cookie.
3. El atributo de autorización no funciona en los componentes
Agrega [Authorize] a un componente pero no bloquea a los usuarios no autenticados.
Solución: Asegúrate de usar AuthorizeRouteView en lugar de RouteView simple en tu App.razor. El estándar RouteView ignora por completo los atributos de autorización.
4. La representación previa interrumpe el estado de autenticación
Durante la representación previa del lado del servidor en Blazor WebAssembly, no hay ningún token de autenticación disponible. Los componentes se muestran como no autenticados y luego parpadean al estado autenticado después de que se carga WASM.
Solución: Desactive la renderización previa para páginas sensibles a la autenticación con @rendermode InteractiveWebAssembly (sin renderización previa) o maneje el estado de carga con elegancia:
<AuthorizeView>
<Authorized>
<p>Welcome back, @context.User.Identity?.Name</p>
</Authorized>
<Authorizing>
<p>Loading...</p>
</Authorizing>
<NotAuthorized>
<a href="/login">Sign in</a>
</NotAuthorized>
</AuthorizeView>
5. Caducidad del token en circuitos de larga duración
Los circuitos de Blazor Server pueden permanecer activos durante horas. Si su token o sesión caduca, el usuario permanece “autenticado” en la interfaz de usuario, pero las llamadas a la API comienzan a fallar.
Solución: Implemente una verificación periódica o use un RevalidatingServerAuthenticationStateProvider:
public class RevalidatingAuthStateProvider
: RevalidatingServerAuthenticationStateProvider
{
private readonly IServiceScopeFactory _scopeFactory;
public RevalidatingAuthStateProvider(
ILoggerFactory loggerFactory,
IServiceScopeFactory scopeFactory)
: base(loggerFactory)
{
_scopeFactory = scopeFactory;
}
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
protected override async Task<bool> ValidateAuthenticationStateAsync(
AuthenticationState authenticationState, CancellationToken cancellationToken)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var userManager = scope.ServiceProvider
.GetRequiredService<UserManager<IdentityUser>>();
var user = await userManager.GetUserAsync(authenticationState.User);
return user is not null;
}
}
Esto valida que el usuario todavía existe (y que su sello de seguridad no ha cambiado) cada 30 minutos.
Conclusión
La autenticación y autorización en Blazor requieren un cambio de mentalidad respecto del tradicional ASP.NET de solicitud-respuesta. La abstracción AuthenticationStateProvider es la clave para comprender cómo encaja todo; una vez que lo interiorizas, el resto sigue de forma natural.
Para la mayoría de las aplicaciones, comience con ASP.NET Identity y las plantillas integradas. Se encargan del trabajo pesado de la administración de usuarios, el hash de contraseñas y la generación de tokens. Aumente las políticas y la autorización basada en reclamos a medida que crezcan sus requisitos. Agregue proveedores de OAuth externos cuando sus usuarios los esperen.
El modelo de alojamiento es importante: Blazor Server le ofrece una postura de seguridad más tradicional en la que el código permanece en el servidor, mientras que WebAssembly le impulsa a pensar primero en API, donde el cliente no es de confianza por diseño. Ninguno de los dos es intrínsecamente más seguro; simplemente tienen diferentes modelos de amenaza.
Cualquiera que sea el enfoque que elija, recuerde la regla de oro: la autorización en la interfaz de usuario es para la experiencia del usuario, la autorización en el servidor es para la seguridad. Siempre aplique ambas.