July 25, 2026
Browser session-cookie auth for Blazor SSR: surviving the F5
Your Blazor app authenticates with a bearer token. Then a user presses F5 on an [Authorize] page and gets bounced to the login screen even…
By Ivan Ball-llovera
7 min read
Your Blazor app authenticates with a bearer token. Then a user presses F5 on an [Authorize] page and gets bounced to the login screen even though they are signed in. The fix is an HttpOnly cookie and a server-side reader, without that reader ever becoming the security boundary.
Part of the MMCA.Common series · Article 25 of 49. One pattern at a time.
You build a Blazor Web App, you wire up JWT auth against your API, and it works. Login returns an access token and a refresh token, the interactive client holds the access token, and every API call carries it as a bearer header. You click around the app, protected pages render, the demo is clean.
Then someone presses F5 on a protected page. Or pastes a deep link into a new tab. Or just refreshes after lunch. The page is decorated [Authorize], and it bounces them to /login, even though they are very much still logged in.
Here is what happened. A Blazor Web App renders in two phases: a server-side (SSR) prerender pass runs on that first request, and only afterward does the interactive phase (Blazor Server or WebAssembly) take over. On a fresh GET, there is no interactive client yet, and the browser issues a plain navigation with no Authorization header. So at prerender time, [Authorize] sees an anonymous request and redirects. The access token sitting in the client's memory, or in localStorage, is no help: SSR runs on the server and cannot read either. And you do not want the refresh token in localStorage anyway, because anything a script can read, an XSS payload can exfiltrate.
Why it matters
This is the gap between "the user has a valid session" and "the server rendering the first paint can see it." A pure bearer-header design has nowhere to put the session that the SSR pass can read. So the choices look like: redirect every fresh GET to a round-trip through login (ugly and wrong, they are logged in), or store the token somewhere the server can read it.
The somewhere has to satisfy three constraints at once. The SSR pass must be able to read the user's identity to render [Authorize] pages. The refresh token must stay unreadable to JavaScript, because it is the long-lived credential. And none of this can become a second, weaker place to validate auth, because you already have one validation boundary (the API) and a second one is a second thing to get wrong.
The answer is an HttpOnly cookie for transport, a non-validating SSR reader for prerender, and a strict rule that the cookie reader is for rendering only. The API stays the boundary.
The MMCA answer: HttpOnly cookies plus an SSR-time reader
The mechanism ships in MMCA.Common.API (a SessionCookies/ folder) with a MMCA.Common.UI companion, and both apps' Web UI hosts wire it. It has four moving parts.
Two HttpOnly cookies, seeded from the client at login. mmca_auth_access carries the JWT and mmca_auth_refresh carries the refresh token (the names are constants on SessionCookieEndpoints). After a successful login the browser POSTs to /auth/session-cookie, and SessionCookieJar.Append writes both cookies HttpOnly with SameSite=Lax; logout DELETEs them. Because they are HttpOnly, no script can read either one.
An SSR-time scheme reads the access cookie, and deliberately does not validate its signature. SessionCookieAuthenticationHandler (scheme "SessionCookie") reads mmca_auth_access during prerender, parses its claims, checks expiry, and populates HttpContext.User so [Authorize] passes.
// SessionCookieAuthenticationHandler.HandleAuthenticateAsync: read claims for prerender, no signature check.
var token = cookieTokenReader.ReadAccessToken();
if (string.IsNullOrWhiteSpace(token))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var jwtHandler = new JwtSecurityTokenHandler();
if (!jwtHandler.CanReadToken(token))
{
return Task.FromResult(AuthenticateResult.Fail("Session cookie is not a valid JWT."));
}
var jwt = jwtHandler.ReadJwtToken(token); // ReadJwtToken, NOT ValidateToken: no signature check
if (jwt.ValidTo < TimeProvider.GetUtcNow().UtcDateTime) // injectable clock, same as the rest of the auth stack
{
return Task.FromResult(AuthenticateResult.Fail("Session cookie JWT is expired."));
}
var identity = new ClaimsIdentity(jwt.Claims, Scheme.Name, ClaimTypes.NameIdentifier, ClaimTypes.Role);
var principal = new ClaimsPrincipal(identity);
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)));// SessionCookieAuthenticationHandler.HandleAuthenticateAsync: read claims for prerender, no signature check.
var token = cookieTokenReader.ReadAccessToken();
if (string.IsNullOrWhiteSpace(token))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var jwtHandler = new JwtSecurityTokenHandler();
if (!jwtHandler.CanReadToken(token))
{
return Task.FromResult(AuthenticateResult.Fail("Session cookie is not a valid JWT."));
}
var jwt = jwtHandler.ReadJwtToken(token); // ReadJwtToken, NOT ValidateToken: no signature check
if (jwt.ValidTo < TimeProvider.GetUtcNow().UtcDateTime) // injectable clock, same as the rest of the auth stack
{
return Task.FromResult(AuthenticateResult.Fail("Session cookie JWT is expired."));
}
var identity = new ClaimsIdentity(jwt.Claims, Scheme.Name, ClaimTypes.NameIdentifier, ClaimTypes.Role);
var principal = new ClaimsPrincipal(identity);
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)));That missing signature check is the part that looks alarming and is actually the crux of the design. The handler calls ReadJwtToken (parse) rather than ValidateToken (verify). It is safe for exactly one reason: the cookie was minted by the UI host from a token the API already issued, and every API call still fully validates the JWT (the cross-service JWKS story from the auth article). The SSR handler only needs the user's claims to render the first paint; it is explicitly not the security boundary.
The refresh token never leaves the server. When the interactive client starts, it calls POST /auth/session/token, a same-origin "validate-or-refresh" endpoint, to hydrate its in-memory access token. CookieSessionRefresher.GetOrRefreshAsync reads the HttpOnly refresh cookie server-side, refreshes the access token if it has expired, writes fresh cookies back, and returns only the access token and its expiry.
// POST /auth/session/token: hydrate the in-memory access token; the refresh token never leaves the server.
endpoints.MapPost("/auth/session/token", async (
HttpContext httpContext, ICookieSessionRefresher refresher, CancellationToken cancellationToken) =>
{
if (IsCrossSite(httpContext.Request)) // Sec-Fetch-Site CSRF guard
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
var result = await refresher.GetOrRefreshAsync(httpContext, cancellationToken).ConfigureAwait(false);
return result is null
? Results.Json(new { error = "no_session" }, statusCode: StatusCodes.Status401Unauthorized)
: Results.Json(new SessionTokenResponse(result.Value.AccessToken, result.Value.AccessTokenExpiry));
});// POST /auth/session/token: hydrate the in-memory access token; the refresh token never leaves the server.
endpoints.MapPost("/auth/session/token", async (
HttpContext httpContext, ICookieSessionRefresher refresher, CancellationToken cancellationToken) =>
{
if (IsCrossSite(httpContext.Request)) // Sec-Fetch-Site CSRF guard
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
var result = await refresher.GetOrRefreshAsync(httpContext, cancellationToken).ConfigureAwait(false);
return result is null
? Results.Json(new { error = "no_session" }, statusCode: StatusCodes.Status401Unauthorized)
: Results.Json(new SessionTokenResponse(result.Value.AccessToken, result.Value.AccessTokenExpiry));
});The response is a SessionTokenResponse(AccessToken, AccessTokenExpiry). The refresh token is never serialized to the browser. On the UI side, ITokenStorageService keeps the access token in memory and treats the refresh token as living only in the HttpOnly cookie, and ISessionCookieSync (implemented by JsFetchSessionCookieSync) mirrors the client's tokens back into the cookies via a small JS shim so the next prerender sees a current session.
A refresh middleware closes the expiry race. CookieSessionRefreshMiddleware runs before UseAuthentication and, only on GET requests that accept HTML, refreshes the access token server-side if it has expired but the refresh cookie is still good. So a fresh GET after the access token lapsed still renders authenticated, because the middleware has already minted a fresh access token (read by CookieTokenReader from HttpContext.Items) before the SSR scheme runs.
This is a backend-for-frontend (BFF) token-storage layer: the browser holds an HttpOnly session whose refresh half it cannot read, the SSR pass authenticates from the access cookie without trusting it as the boundary, and the real enforcement stays at the API.
The CSRF tax, and how it is paid
Cookie-based auth reintroduces a concern that a pure bearer-header design does not have: a cross-site page can cause the browser to send your cookies. The framework pays that tax with defense-in-depth rather than a single control. The cookies are SameSite=Lax, so they are not sent on cross-site subrequests. The refresh endpoint additionally rejects cross-site POSTs by checking the Sec-Fetch-Site header (IsCrossSite). The cookie endpoints disable antiforgery deliberately (they carry no antiforgery token and are guarded by SameSite + Sec-Fetch-Site + POST-only instead). Three overlapping checks, none of them load-bearing alone.
Trade-offs, honestly
The pattern fixes a real gap, and the §26 review names what it costs.
- A non-validating auth scheme exists in your codebase.
SessionCookieAuthenticationHandlertrusts a cookie it does not cryptographically verify. That is sound only because the cookie is HttpOnly and host-minted and the API independently validates every call. Authorize a sensitive action on the SSR principal without an API round-trip and you have broken the safety argument. The rule "the API is the boundary" is not a nicety here, it is the whole proof. - The session now lives in two places. Cookies for browser GETs and SSR, an in-memory bearer token for interactive API calls. The two must stay in sync, which is exactly what
/auth/session/tokenand the UI'sISessionCookieSyncexist to manage. A bug in that sync shows up as a flicker of the wrong auth state. - CSRF surface a header-only design would not have.
SameSite=LaxplusSec-Fetch-Siteplus POST-only is solid, but it is a surface, and a reviewer has to understand all three to trust the cookie endpoints. - Expiry is checked, not cryptographically enforced, at the SSR edge. A tampered cookie produces claims that fail at the API on the next call, but the one prerendered HTML pass is produced from unverified claims. For rendering, that is acceptable; for a decision, it is not, which loops back to the first point.
None of these are reasons to redirect every F5 to a login round-trip. They are the reasons to keep the SSR reader honest about what it is for.
Apply this even without MMCA
The shape ports to any server-prerendered SPA with token auth:
- Put the session in an HttpOnly cookie, not
localStorage. The server can read a cookie during render; it cannot readlocalStorage. And the refresh token belongs somewhere a script cannot reach it. - Let the prerender pass read claims, not verify them, and make the API the only validator. A parse-don't-validate reader is safe only if a real validation boundary sits behind every state-changing call. Write that invariant down.
- Keep the refresh token server-side behind a hydrate endpoint. The browser asks a same-origin endpoint for a fresh access token; the refresh token is exchanged on the server and never serialized to the client.
- Refresh before authentication on GETs. A small middleware that mints a fresh access token before the auth handler runs closes the "expired between requests" gap without a client round-trip.
- Budget for CSRF. Cookies bring it back.
SameSite=Laxplus aSec-Fetch-Sitecheck plus POST-only endpoints is a defensible, layered answer.
The takeaway: the SSR prerender gap is real, and the fix is an HttpOnly cookie the server can read plus the discipline that reading it is for rendering, never for deciding. Keep the refresh token off the client, keep the API as the one validator, and an [Authorize] page survives an F5 without weakening anything.
What we covered: why a Blazor Web App's SSR prerender pass bounces [Authorize] pages to login on a fresh GET (no Authorization header, no server-readable token), how MMCA.Common closes the gap with two HttpOnly cookies, a SessionCookieAuthenticationHandler that reads claims for prerender without validating the signature, a server-side /auth/session/token refresher that keeps the refresh token off the client, and a pre-auth refresh middleware, why the design is safe only because the API stays the validation boundary, and the CSRF and dual-path costs that buys.
Next in the series: external OAuth login, adding Google and GitHub sign-in behind your own local JWTs (ADR-036), where an OAuthControllerBase swaps a single-use, short-lived cached code for the JWT pair so the tokens never ride the redirect URL.
MMCA.Common is Apache-2.0 licensed and open source. Star the repo, read the 2-minute ADR-022 behind this pattern, or dotnet add package MMCA.Common.API and try it.
- ⭐ Repo: https://github.com/ivanball/MMCA.Common
- 📚 Full series index: https://ivanball.github.io/writing.html
- 📄 ADR-022 (browser session-cookie auth): https://ivanball.github.io/docs/adr/022-browser-session-cookie-auth.html.