↑↓ to navigate Enter to open "…" all these words ANDOR to combine

Auth & the edge · No. 25

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 though they are signed in. The fix is an HttpOnly cookie and a server-side reader, without that reader ever becoming the security boundary.


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)));

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));
});

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.

Scaling out: the key ring has to be shared

One replica is a special case. Add a second and a failure appears that has nothing to do with the design above: ASP.NET Core's DataProtection key ring lives in memory, per process, so every replica generates its own keys and a payload minted by replica A cannot be decrypted by replica B. The symptom follows the load balancer rather than the user: random sign-outs and "The antiforgery token could not be decrypted" errors that fit no pattern.

AddCommonDataProtection (ADR-069) is the one-call answer. Given a DataProtection:BlobStorageUri it persists the key ring to a single Azure blob under DefaultAzureCredential and sets an application discriminator, so the cookies and antiforgery tokens minted by one replica decrypt on another. Encrypting that key ring at rest with a Key Vault key is a second, deliberately independent gate (DataProtection:KeyVaultKeyUri): the correctness fix has to work without the Key Vault Crypto User role, because that role assignment is granted out of band and can lag a deployment, and folding the two into one switch would turn an optional hardening gap into a total authentication outage. Both consumer templates ship that second gate with its parameter defaulting to off, and the deployed value of that parameter is not readable from the repositories, so the record states what ships rather than what is switched on: on default parameters the ring is protected by a private container and a narrow account grant instead of by a Key Vault key. ADR-069 keeps that as a named trade-off rather than hiding it.

Absent configuration is a full no-op: with no blob URI the method returns the builder untouched and registers no DataProtection services at all. A developer machine, a test host and the Helpdesk seed all run single-process, keep the in-memory default, and take no Azure dependency at startup.

Adoption is per host, and the shape of it is worth saying plainly. MMCA.ADC calls it on exactly the two hosts that mint those payloads, its Identity service and its Web UI host, and points both at one private container on a storage account it already had. MMCA.Store's Web UI host calls it as well, on a storage account provisioned for this and nothing else, with one extra catch on the infrastructure side: the blob URI is injected only when a dataProtectionStorageReady parameter is true (it defaults to false), because AddCommonDataProtection gates on the presence of the URI and never on its reachability, so wiring the URI before the blob role assignment exists would fail the first protect call rather than degrade. That flag is true in production today. Store's Identity service still makes no call, and its container app also runs two replicas.

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. SessionCookieAuthenticationHandler trusts 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/token and the UI's ISessionCookieSync exist 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=Lax plus Sec-Fetch-Site plus 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.
  • Scaling out adds a shared-state dependency. A cookie session is only as portable as the key ring behind it, so a multi-replica host has to persist that ring where every replica can read it (ADR-069): one more piece of infrastructure and one more credential that has to resolve at startup. It is also opt-in per host, so adoption has to be audited. A scaled-out host that never makes the call keeps the broken per-replica default and fails intermittently rather than loudly.

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:

  1. Put the session in an HttpOnly cookie, not localStorage. The server can read a cookie during render; it cannot read localStorage. And the refresh token belongs somewhere a script cannot reach it.
  2. 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.
  3. 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.
  4. 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.
  5. Budget for CSRF. Cookies bring it back. SameSite=Lax plus a Sec-Fetch-Site check plus POST-only endpoints is a defensible, layered answer.
  6. Share the key ring before you scale out. The moment a cookie-minting host runs more than one replica, per-process keys become random sign-outs. Persist the ring to one shared store with an application discriminator, and keep encryption of the ring at rest as a separate switch so a lagging role assignment costs you hardening rather than login.

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, why a scaled-out cookie host also needs a shared DataProtection key ring (ADR-069, adopted by both apps, with Store's blob URI gated behind a readiness flag), 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.

Tags: .NET, C Sharp, Blazor, Security, Authentication