A travel application uses Blazor WebAssembly for an internal booking editor. An employee opens a booking, changes the departure date, and submits a form that Blazor marks as valid. The save request still fails because the access token expired while the page was open.
Another employee discovers a more serious problem. By changing the request outside the user interface, it is possible to submit values that the browser form would reject. The Blazor validation messages improve the editing experience, but they do not protect the server.
The solution requires several controls working together. The page must load data at the correct component lifecycle stage, the form must use a validation-friendly ViewModel, the client must send and renew bearer tokens through an authorization-aware HttpClient, the API must enforce Cross-Origin Resource Sharing rules, and the server must repeat validation and authorization before changing business data.
Context and Scope
The example contains four components:
- A Blazor WebAssembly Single-Page Application, or SPA
- An OAuth or OpenID Connect identity provider
- An ASP.NET Core Web API
- A server-side booking service that enforces business rules
Browser
|
+--> Blazor WebAssembly application
| |
| +--> Routing and components
| +--> EditForm validation
| +--> Authorization-aware HttpClient
|
+--> Identity provider
|
v
ASP.NET Core Web API
|
+--> CORS policy
+--> Token validation
+--> Authorization policy
+--> Server-side request validation
|
v
Booking domain and persistence
The browser is responsible for presentation and interaction. It is not trusted to protect business rules. Users can inspect and alter code, requests, and local state on their own devices.
The server remains responsible for:
- Validating the access token
- Deciding whether the caller may edit the booking
- Validating request data
- Enforcing booking rules
- Persisting the change
Why Client Validation Is Not a Security Boundary
Blazor forms provide immediate feedback without sending every keystroke to the server. That makes the interface responsive and helps users correct mistakes before submission.
It does not guarantee data integrity.
A user can bypass the rendered form and send a modified request directly to the API. Any rule enforced only by a Blazor component can therefore be skipped.
Use client validation for usability:
- Required-field messages
- Accepted numeric ranges
- Date and text formatting
- Immediate visual feedback
- Preventing an accidental invalid submission
Use server validation for trust:
- Authorization
- Business invariants
- Resource ownership
- Current availability
- Date rules based on server time
- Values that depend on stored data
A successful EditForm submission means only that the client-side model passed the configured validators. It does not mean that the server must accept the operation.
Step 1: Use a Flattened Form ViewModel
Blazor's EditForm coordinates input components through an EditContext. The DataAnnotationsValidator applies normal .NET validation attributes, and validation components listen for changes in the form state.
Input components bound to nested properties are not validated reliably by the standard form arrangement described here. Use a flattened ViewModel for one editing task instead of binding the form directly to a deep domain object graph.
public sealed class BookingEditModel
{
public int BookingId { get; init; }
[Required]
[StringLength(120)]
public string TravellerName { get; set; } = string.Empty;
[Required]
public string DestinationCode { get; set; } = string.Empty;
[Range(1, 12)]
public int TravellerCount { get; set; } = 1;
public DateOnly DepartureDate { get; set; }
[StringLength(500)]
public string? SpecialRequest { get; set; }
}
The model contains only values needed by the booking editor. It does not expose database entities or unrelated domain state.
The page can compose the form from these Blazor components:
EditFormDataAnnotationsValidatorValidationSummaryInputTextInputNumberInputDateValidationMessage
Handle the valid submission through OnValidSubmit. Use OnInvalidSubmit only when the page needs extra behavior after a validation failure.
When field-level events are required, create an EditContext explicitly and subscribe to OnFieldChanged or OnValidationStateChanged. Otherwise, passing the form model is simpler.
Step 2: Load Route Data at the Correct Lifecycle Stage
The editor route contains the booking identifier:
@page "/bookings/edit/{BookingId:int}"
@attribute [Authorize(Policy = "BookingEditors")]
@inject BookingApiClient BookingApi
@code {
[Parameter]
public int BookingId { get; set; }
private BookingEditModel Model { get; set; } = new();
private EditContext? EditContext { get; set; }
protected override async Task OnParametersSetAsync()
{
Model = await BookingApi.GetAsync(BookingId);
EditContext = new EditContext(Model);
}
private async Task SaveAsync()
{
await BookingApi.UpdateAsync(Model);
}
}
Use OnParametersSetAsync() because the loaded data depends on BookingId. Blazor calls this lifecycle method when component parameters change.
Use OnInitialized() or OnInitializedAsync() for work that happens once when the component is created and does not depend on changing route parameters.
Do not place normal data loading in OnAfterRenderAsync(). That method runs after rendering and is appropriate when code must interact with generated content, such as a JavaScript interoperability call that requires the rendered page. Loading application data there can cause unnecessary render and request cycles.
ShouldRender() can suppress rendering, but it is an advanced optimization. First correct the component state and lifecycle logic. Do not hide repeated data loading behind a rendering override.
Step 3: Protect the Route and the Server Operation
Blazor normally uses AuthorizeRouteView in the root router to evaluate authorization metadata on page components. An unauthenticated user can be redirected to the login flow, while an authenticated user without the required policy receives an unauthorized result.
AuthorizeView can show or hide parts of a page according to roles or policies. It is useful for presentation, but it does not secure the API.
A hidden button is not an authorization rule.
The API must validate the token and apply the same required permission before updating the booking. This protects the operation even when the request is created outside the Blazor application.
[ApiController]
[Route("api/bookings")]
[Authorize(Policy = "BookingEditors")]
public sealed class BookingsController : ControllerBase
{
private readonly IBookingApplicationService _bookings;
public BookingsController(
IBookingApplicationService bookings)
{
_bookings = bookings;
}
[HttpPut("{bookingId:int}")]
public async Task<IActionResult> Update(
int bookingId,
BookingEditModel request,
CancellationToken cancellationToken)
{
if (bookingId != request.BookingId)
{
return BadRequest();
}
await _bookings.UpdateAsync(
request,
User,
cancellationToken);
return NoContent();
}
}
The application service should still enforce domain rules, such as whether the departure date may be changed and whether the authenticated employee may modify that booking.
Client authorization improves navigation. Server authorization protects data.
Step 4: Configure Blazor Authentication
A Blazor WebAssembly application can register OpenID Connect authentication through AddOidcAuthentication.
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.Services.AddOidcAuthentication(options =>
{
builder.Configuration.Bind(
"Local",
options.ProviderOptions);
});
await builder.Build().RunAsync();
The authentication provider returns user information and bearer tokens. Blazor supplies an AuthenticationStateProvider so routing and components can evaluate the current identity.
The application configuration downloaded to the browser must not contain secrets. Browser users can inspect client files and application code.
The authentication route normally uses RemoteAuthenticatorView to coordinate login, logout, and callback actions with the identity provider. The router can redirect unauthenticated users to that flow when they open a protected page.
Step 5: Use an Authorization-Aware HttpClient
A normal HttpClient does not automatically attach the access token expected by the protected API.
Register a named client and add BaseAddressAuthorizationMessageHandler.
builder.Services
.AddHttpClient(
"Travel.BookingApi",
client =>
{
client.BaseAddress =
new Uri("https://api.travel.example/");
})
.AddHttpMessageHandler<
BaseAddressAuthorizationMessageHandler>();
builder.Services.AddScoped<BookingApiClient>();
The authorization handler adds a bearer token obtained during authentication. When the existing token is missing or expired, it attempts to acquire a usable token through the established authentication session.
If that attempt fails, the request throws AccessTokenNotAvailableException. Catch it and redirect the user through the login flow.
public sealed class BookingApiClient
{
private readonly IHttpClientFactory _clientFactory;
public BookingApiClient(
IHttpClientFactory clientFactory)
{
_clientFactory = clientFactory;
}
public async Task<BookingEditModel> GetAsync(
int bookingId,
CancellationToken cancellationToken = default)
{
try
{
var client = _clientFactory.CreateClient(
"Travel.BookingApi");
var model = await client.GetFromJsonAsync<
BookingEditModel>(
$"api/bookings/{bookingId}",
cancellationToken);
return model
?? throw new InvalidOperationException(
"The booking response was empty.");
}
catch (AccessTokenNotAvailableException exception)
{
exception.Redirect();
throw;
}
}
public async Task UpdateAsync(
BookingEditModel model,
CancellationToken cancellationToken = default)
{
try
{
var client = _clientFactory.CreateClient(
"Travel.BookingApi");
using var response = await client.PutAsJsonAsync(
$"api/bookings/{model.BookingId}",
model,
cancellationToken);
response.EnsureSuccessStatusCode();
}
catch (AccessTokenNotAvailableException exception)
{
exception.Redirect();
}
}
}
The redirect is not a general error-handling strategy. Handle normal server responses separately, including validation failures, forbidden operations, missing bookings, and unexpected failures.
Step 6: Configure CORS for Separate Origins
When the Blazor application and API use different origins, the browser applies Cross-Origin Resource Sharing, or CORS, rules.
The API must explicitly allow the Blazor application's origin. It must also allow the headers and methods used by authenticated requests.
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy
.WithOrigins(
"https://portal.travel.example")
.WithHeaders(
HeaderNames.ContentType,
HeaderNames.Authorization)
.AllowAnyMethod();
});
});
var app = builder.Build();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
List known frontend origins. Do not use CORS as authentication. CORS is a browser policy that controls which origins may call the API from browser code. The API still needs token validation and authorization.
When the Blazor application and API are deployed below the same origin, cross-origin rules are not involved.
Step 7: Validate Again on the Server
The API should validate the incoming request independently from the browser.
Data annotation validation can reject malformed input at the HTTP boundary. The booking application service and domain model must then validate rules that require trusted state.
public sealed class BookingApplicationService :
IBookingApplicationService
{
private readonly IBookingRepository _repository;
public BookingApplicationService(
IBookingRepository repository)
{
_repository = repository;
}
public async Task UpdateAsync(
BookingEditModel request,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var booking = await _repository.FindAsync(
request.BookingId,
cancellationToken);
if (booking is null)
{
throw new InvalidOperationException(
"The booking was not found.");
}
booking.EnsureEditorMayChange(user);
booking.ChangeTravelDetails(
request.TravellerName,
request.DestinationCode,
request.TravellerCount,
request.DepartureDate,
request.SpecialRequest);
await _repository.SaveChangesAsync(
cancellationToken);
}
}
The browser may suggest valid values. Only the server decides whether a state change is permitted.
Understand the Remaining Blazor Tradeoffs
Blazor WebAssembly provides a rich client experience with C# and Razor components, but the initial application download is larger than a minimal JavaScript client because the browser also needs the .NET runtime and supporting libraries.
Release builds remove unused code, and browser caching reduces later downloads. Ahead-of-Time, or AOT, compilation can improve compute-intensive execution, but it increases publication time and more than doubles the download size described for this mode.
Do not enable AOT to solve slow API requests, expired tokens, or incorrect component lifecycle code. It is a tradeoff for applications where client-side computation is important enough to justify a larger initial download.
When browser restrictions prevent the required device integration, .NET MAUI Blazor can reuse Blazor components and concepts inside a native cross-platform application. The security rule remains unchanged: device-side validation and hidden controls do not replace server authorization and business validation.
Testing the Workflow
Test the page as a complete interaction rather than checking only one validation attribute.
Component tests
The bUnit project provides tools for testing Blazor components. Verify that:
- Invalid values prevent the valid-submit path.
- Valid values invoke the save behavior.
- A changed route parameter loads the new booking.
- Authorization-sensitive content follows the current authentication state.
- Component events update the expected parent state.
API tests
Verify that:
- A request without a valid token is rejected.
- An authenticated caller without the editing permission is rejected.
- A modified request that bypasses client validation is rejected.
- A valid request changes only the authorized booking.
- Route and body identifiers must agree.
Authentication tests
Verify that:
- A valid token is attached to API calls.
- An expired token is renewed when the authentication session permits it.
- Failure to acquire a token triggers the login redirect.
- Ordinary API failures are not mistaken for authentication failures.
CORS tests
Verify that the deployed frontend origin is accepted and an unlisted origin is not accepted by the browser policy.
Common Mistakes
Treating EditForm validation as data protection
Client validation is visible and modifiable. Repeat validation and authorization on the server.
Binding a large nested domain model directly to the form
Use a flattened ViewModel for the editing task. Keep domain entities and internal state on the server.
Loading route-dependent data only during initialization
A reused component can receive a new route parameter. Load parameter-dependent state in OnParametersSetAsync().
Calling the API from OnAfterRenderAsync
Use after-render lifecycle methods for work that requires rendered content. Normal page data loading belongs earlier.
Hiding controls instead of authorizing operations
AuthorizeView controls presentation. The protected API must enforce permission independently.
Using a plain HttpClient for protected calls
Register an authorization message handler so bearer tokens are attached and token loss follows a controlled login path.
Allowing every origin through CORS
Allow only known frontend origins and the required headers and methods.
Enabling AOT before diagnosing the real delay
AOT improves compute-heavy client code at the cost of a larger download. It does not repair network, authentication, or lifecycle problems.
Implementation Checklist
- [ ] Use a task-specific flattened ViewModel
- [ ] Apply data annotation validation for immediate user feedback
- [ ] Handle valid submission through the form validation workflow
- [ ] Load route-dependent data in
OnParametersSetAsync() - [ ] Reserve after-render methods for rendered-content integration
- [ ] Protect pages with authorization metadata
- [ ] Treat hidden UI elements as presentation only
- [ ] Validate tokens and permissions at the API
- [ ] Register OpenID Connect authentication
- [ ] Use an authorization-aware named HttpClient
- [ ] Redirect when no access token can be acquired
- [ ] Handle normal API errors separately
- [ ] Configure CORS for known frontend origins
- [ ] Repeat request and business validation on the server
- [ ] Test invalid, expired-token, forbidden, and route-change paths
- [ ] Consider AOT only for measured client-side computation needs
Conclusion
A Blazor WebAssembly form can be valid and still fail for legitimate reasons. The token may have expired, the caller may lack permission, the resource may have changed, or the server may reject a rule that the client cannot evaluate safely.
Build the page so each responsibility is clear. Blazor components manage interaction, EditForm improves input quality, lifecycle methods control when data loads, the authorization handler manages protected API calls, CORS controls browser origins, and the server makes the final validation and authorization decisions.
That design preserves the responsive client experience without treating code running in the browser as a trusted security boundary.