When .NET 8 introduced the unified Blazor Web App model, Microsoft published official migration documentation to move existing Blazor Server projects across. On paper it looks straightforward: create a Routes.razor file, move a few things around, done. In practice, if your application has a custom layout and is secured with OpenID Connect, the official guide leaves a few gaps that you only discover once you start moving code. This walkthrough covers a real migration of a Blazor Server application secured with OpenID Connect, using OpenIddict as the identity provider, into the InteractiveServer render mode of Blazor Web.
The starting point is a Blazor Server application that authenticates using the OpenID Connect code flow, applies security headers including a Content Security Policy with per-request nonces, and lets users sign in and sign out through OpenIddict. This is not a toy sample. Security headers and CSP nonces make the migration noticeably harder than the documentation suggests, because Blazor Web changes how components can read the current HTTP context.
Rebuilding App.razor from _Host.cshtml
The official steps 1 to 3 are simple enough: create Routes.razor, and extend the using statements. The real work starts when you migrate the contents of Pages/_Host.cshtml into App.razor. If your original application only used the default layout, this step is close to mechanical. If you have a custom Layout wired into _Host.cshtml, as this application does, that layout logic has to move into App.razor as well, and the official docs do not call this out explicitly.
Here is the completed App.razor file after migration.
@inject IHostEnvironment Env
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
<link href="css/site.css" rel="stylesheet" />
<link href="BlazorWebFromBlazorServerOidc.styles.css" rel="stylesheet" />
<HeadOutlet @rendermode="InteractiveServer" />
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<script nonce="@BlazorNonceService.Nonce" src="_framework/blazor.web.js"></script>
</body>
</html>
Two things stand out here compared to the old _Host.cshtml. First, the render mode is now declared explicitly on both HeadOutlet and Routes, using @rendermode=”InteractiveServer”. Second, the script tag that loads blazor.web.js carries a CSP nonce sourced from a custom BlazorNonceService, not the older blazor.server.js. If you forget to update this script reference, the app will build fine and fail silently at runtime because the old script simply does not exist in a Blazor Web project.
Routes.razor and the CascadingAuthenticationState
Routes.razor is where the router and the authentication cascading state now live, replacing what used to sit inside App.razor in classic Blazor Server. The important detail for a secured application is that CascadingAuthenticationState has to wrap the Router, and the NotAuthorized branch needs to redirect to your login endpoint with a forced full page load rather than a client-side navigation.
@inject NavigationManager NavigationManager
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)">
<NotAuthorized>
@{
var returnUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
NavigationManager.NavigateTo($"api/account/login?redirectUri={returnUrl}", forceLoad: true);
}
</NotAuthorized>
<Authorizing>
Wait...
</Authorizing>
</AuthorizeRouteView>
</Found>
<NotFound>
<LayoutView Layout="@typeof(Layout.MainLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
The forceLoad: true parameter on NavigateTo matters more than it looks. Without it, Blazor tries to handle the redirect to the login endpoint through its own SignalR circuit, which does not trigger the OpenID Connect challenge correctly. This is a common mistake when developers copy the Microsoft sample without noticing that detail, and the symptom is usually a login link that appears to do nothing.
MainLayout and the login, logout component
The custom layout that used to be referenced from _Host.cshtml now becomes its own MainLayout.razor component, pulled in through DefaultLayout on AuthorizeRouteView above. It composes a NavMenu component and a small LogInOrOut component for the sign-in and sign-out links.
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<LogInOrOut />
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">Dismiss</a>
</div>
There is nothing unusual in this layout on its own, it is a fairly standard Blazor sidebar layout. The point worth noting is that this component did not exist as a separate file in the original Blazor Server project; the layout markup was embedded inside _Host.cshtml. Extracting it into its own MainLayout.razor is a mandatory step for any project with a custom layout, and skipping it is the most common reason a migration ends up half broken.
The LogInOrOut component reuses the existing account controller endpoints for login and logout, so no changes were needed on the authentication backend itself.
@inject NavigationManager NavigationManager
<AuthorizeView>
<Authorized>
<div class="nav-item">
<span>@context.User.Identity?.Name</span>
</div>
<div class="nav-item">
<form action="api/account/logout" method="post">
<AntiforgeryToken />
<button type="submit" class="nav-link btn btn-link text-dark">
Logout
</button>
</form>
</div>
</Authorized>
<NotAuthorized>
<div class="nav-item">
<a href="api/account/login?redirectUri=/">Log in</a>
</div>
</NotAuthorized>
</AuthorizeView>
Logout goes through a POST form with an antiforgery token rather than a plain link. This is a deliberate improvement over the original Blazor Server version, since a GET-based logout link is a minor CSRF-adjacent smell that is easy to introduce without noticing. If you are migrating your own app, this is a good moment to fix that pattern rather than carry it forward unchanged.
Program.cs and the security header trade-off
The Program.cs changes mostly follow the official migration guide: swap AddServerSideBlazor for AddRazorComponents().AddInteractiveServerComponents(), and map the root component with MapRazorComponents<App>().AddInteractiveServerRenderMode(). The part that is not obvious from the docs is what happens to security headers.
var builder = WebApplication.CreateBuilder(args);
builder.Services.TryAddEnumerable(ServiceDescriptor.Scoped<CircuitHandler, BlazorNonceService>
(sp => sp.GetRequiredService<BlazorNonceService>()));
builder.Services.AddScoped<BlazorNonceService>();
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
builder.Configuration.GetSection("OpenIDConnectSettings").Bind(options);
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.ResponseType = OpenIdConnectResponseType.Code;
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name"
};
});
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
var app = builder.Build();
app.UseSecurityHeaders(
SecurityHeadersDefinitions.GetHeaderPolicyCollection(app.Environment.IsDevelopment(),
app.Configuration["OpenIDConnectSettings:Authority"]));
app.UseMiddleware<NonceMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode().RequireAuthorization();
app.Run();
When this migration was first done, Blazor Web components could not read the current HTTP context the way Razor Pages and MVC views could, which meant the CSP nonce generated per request was not reachable from inside a component. The pragmatic but unwelcome workaround at the time was to weaken the Content Security Policy, dropping strict nonce-based script control. That is a real regression, not a cosmetic one, because CSP nonces are one of the more effective defenses against injected script execution.
This is worth calling out for anyone planning a similar migration: do not assume your security posture carries over unchanged just because the app compiles and the login flow still works. Test your CSP headers specifically, and treat a passing OpenID Connect flow as no signal at all about whether your CSP is still doing its job.

The nonce gap was later closed
The good news is that this was not a permanent ceiling. A later update to this same migration, tracked against upstream aspnetcore issues, restored nonce support in the InteractiveServer render mode through a custom BlazorNonceService combined with a NonceMiddleware that generates a per-request nonce and exposes it to both the HTTP response headers and the component tree. If you are doing this migration today, it is worth pulling in that pattern from the start rather than shipping with a weakened CSP and circling back later, since most teams do not circle back.
In InteractiveServer mode specifically, the SignalR circuit gives you a way to flow the nonce value down to the script tag through a scoped service registered as a CircuitHandler, which is exactly what BlazorNonceService does in the Program.cs above. This does not extend to WebAssembly or Auto render modes without further work, since those modes do not have a persistent server-side circuit to hang the nonce service off. If your components use InteractiveAuto or InteractiveWebAssembly, budget separate time to verify CSP behavior for those render modes rather than assuming the server-mode fix covers everything.
Practical takeaways for planning your own migration
A few things are worth deciding upfront before you start moving files around. First, if your app has a custom layout, plan on extracting a MainLayout.razor as a discrete step; the official guide treats this as implicit. Second, audit every NavigateTo call used for authentication redirects and make sure forceLoad is set where a full OpenID Connect challenge is required. Third, do not treat a successful login as proof your security headers survived the migration; check your CSP explicitly, ideally with an automated test or a browser devtools pass, not just a manual click-through.
If your application leans heavily on reading HTTP headers or the HttpContext from inside components, and you are not ready to restructure around a nonce middleware pattern, it may be reasonable to stay on classic Blazor Server for another release cycle rather than migrate under time pressure. The unified Blazor Web model is the direction .NET is heading, but migrating a security-sensitive application deserves the same rigor as any other authentication change, not a rushed weekend port.
Leave a Reply