Blazor Web App(.NET 8) Interactive Autoで本番だけHttpContextがnullになる原因と解決策【WASM/Server/プリレンダリング徹底解説】

Blazor Web App(.NET 8/Interactive Auto)で開発時は動くのに、本番だけ IHttpContextAccessor.HttpContext が null になり認可ヘッダの付与が失敗する――この“あるある”を、レンダリングモード・Cookie・SignalR の観点から分解し、再発しない設計と実装の全体像をコード付きで解説します。

目次

問題の背景と症状の整理

想定される構成は次のとおりです。

  • .NET 8 の Blazor Web App。レンダリングモードは Interactive Auto。
  • ログイン後、DelegatingHandler(例:AuthorizationHandler)が Cookie access_token を読み取り、HttpClient の Authorization ヘッダに付与。
  • 開発環境では動作するが、本番環境では IHttpContextAccessor.HttpContext が null のため Cookie を読めず、API が 401/403。

この差は「どこで HttpClient が呼ばれているか」に尽きます。開発ではサーバー側で呼ばれていたのに、本番ではブラウザ(WASM)側で呼ばれている――それにより HttpContext が存在しないこと、さらに HttpOnly/SameSite の Cookie をクライアント JS が読めないことが重なって失敗します。

Blazor(.NET 8)の実行コンテキストと HttpContext の関係

まずは「いつ HttpContext が使えるのか」を腹落ちさせましょう。

フェーズ/モード実行場所HttpContext 可用性備考(実務での落とし穴)
SSR プリレンダリングサーバー◯(リクエスト中のみ)ここだけは確実に取得可。PersistentComponentState で値を埋め込める。
Interactive Server(回線確立後)サーバー(SignalR 回線上)×(基本なし)イベント処理は HttpContext と無関係。要求単位ではなく「回線(回路)」単位。
Interactive WebAssemblyブラウザ(WASM)×ブラウザにはサーバーの HttpContext という概念がない。
Interactive Auto状況により Server or WASM△(SSR 中だけ◯)本番でインフラやネットワークにより WASM 側に倒れると、クライアントで常時 null。

つまり、「Auto =常に Server で動く」ではない点が最大の誤解ポイントです。WebSocket/SignalR が張れなかったり、CDN・プロキシ・CORS 設定次第で WASM 側にスイッチし、そこで Cookie 依存のハンドラが崩れます。

なぜ開発は成功し本番で失敗するのか(因果を分解)

  1. Interactive Auto の特性:HttpContext は SSR(プリレンダリング)の間だけ有効。対話フェーズでは基本使えません。
  2. 呼び出し元の違い:開発時はサーバーで HttpClient が実行されていたが、本番はブラウザ(WASM)で実行。結果として IHttpContextAccessor.HttpContext は常に null。
  3. Cookie 依存設計:WASM からは HttpOnly Cookie を読めず、SameSite 設定やクロスサイト条件も絡んで DelegatingHandler が破綻。
  4. SignalR/WebSocket 未接続:本番で回線が張れず Server では動かない → Auto が WASM 側に倒れる → さらに HttpContext 不在が顕在化。

下図は開発(左)と本番(右)の違いを示すイメージです。

開発(動く)                         本番(落ちる)
[SSR]-->[Interactive Server]         [SSR]-->[Interactive Auto -> WASM]
  |            |                          |              |
 HttpContext   |(イベント時は無し)       HttpContext     WASM のため無し
 Cookie読取可  |                          Cookie読取可    Cookie読取不可
  ↓            ↓                          ↓              ↓
 DelegatingHandler で付与            DelegatingHandler は Cookie に触れず失敗

結論(再発しない設計指針)

まず「Blazor のクライアントから Cookie を直接読む前提を捨てる」ことが出発点です。サーバー用とクライアント用のパイプを分け、役割ごとに最小限の責務を与えます。

手順対応内容ポイント
1HttpContext 依存コードを分離Cookie を読むハンドラは「サーバー専用」。WASM 側 HttpClient には適用しない。
2プリレンダリングで必要最小限の状態を永続化PersistentComponentState で “ブートストラップ用” の短命トークンやフラグを渡す。
3Custom AuthenticationStateProviderWASM では AuthenticationState をトークンから復元。API 呼び出しはメッセージハンドラで自動付与。
4サーバーではミドルウェアで Cookie→スコープサービスへIHttpContextAccessor に直依存せず、ミドルウェアで解析してスコープに格納。
5本番の動作前提を明示WebSocket/SignalR・CORS・HTTPS・Cookie 属性(SameSite=None; Secure)を点検。
6詳細ログで「どこで呼ばれたか」を可視化ハンドラに OperatingSystem.IsBrowser()・スレッド・トレース ID を出力。

実装ステップ(サーバー/クライアント二系統化)

サーバー側:Cookie をミドルウェアで読み、スコープサービスへ保管

サーバーに入ったリクエストだけ Cookie を読み、「今のスコープ」 で参照できるようにします。

// AccessToken を運ぶだけのスコープサービス
public sealed class AccessTokenHolder
{
    public string? Token { get; set; }
}

// Cookie を読むミドルウェア
public sealed class CookieTokenMiddleware
{
private readonly RequestDelegate _next;
public CookieTokenMiddleware(RequestDelegate next) => _next = next;


public async Task Invoke(HttpContext context, AccessTokenHolder holder, ILogger<CookieTokenMiddleware> logger)
{
    if (context.Request.Cookies.TryGetValue("access_token", out var token))
    {
        holder.Token = token;
        logger.LogDebug("CookieToken captured. path={Path}", context.Request.Path);
    }
    await _next(context);
}


}

// サーバー専用 DelegatingHandler(Authorization ヘッダ付与)
public sealed class ServerCookieAuthorizationHandler : DelegatingHandler
{
private readonly AccessTokenHolder _holder;
private readonly ILogger _logger;


public ServerCookieAuthorizationHandler(AccessTokenHolder holder, ILogger<ServerCookieAuthorizationHandler> logger)
{
    _holder = holder;
    _logger = logger;
}

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
    if (!string.IsNullOrWhiteSpace(_holder.Token))
    {
        request.Headers.Authorization =
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _holder.Token);
        _logger.LogDebug("Auth header injected (server).");
    }
    else
    {
        _logger.LogWarning("No token in scope (server).");
    }
    return base.SendAsync(request, ct);
}


} 
// Program.cs(サーバー登録)
builder.Services.AddScoped&lt;AccessTokenHolder&gt;();
builder.Services.AddHttpContextAccessor();

builder.Services.AddHttpClient("ServerApi", c =&gt;
{
    c.BaseAddress = new Uri("https://api.example.com/");
})
.AddHttpMessageHandler&lt;ServerCookieAuthorizationHandler&gt;();

app.UseMiddleware&lt;CookieTokenMiddleware&gt;();

クライアント(WASM)側:Cookie に頼らずトークンを供給

基本は OIDC(Microsoft.AspNetCore.Components.WebAssembly.Authentication)を使い IAccessTokenProvider から取得し、AuthorizationMessageHandler で自動注入します。

// WASM 用の AuthorizationMessageHandler
public sealed class ApiAuthorizationMessageHandler : AuthorizationMessageHandler
{
    public ApiAuthorizationMessageHandler(
        IAccessTokenProvider provider, NavigationManager nav)
        : base(provider, nav)
    {
        ConfigureHandler(
            authorizedUrls: new[] { "https://api.example.com" },
            scopes: new[] { "api.read" });
    }
}
// Program.cs(WASM 登録の一例)
builder.Services.AddScoped&lt;ApiAuthorizationMessageHandler&gt;();

builder.Services.AddHttpClient("BrowserApi", c =&gt;
{
    c.BaseAddress = new Uri("https://api.example.com/");
})
.AddHttpMessageHandler&lt;ApiAuthorizationMessageHandler&gt;();

OIDC を導入できない事情がある場合の暫定策として、プリレンダリング時だけ サーバーで取り出した短命の “ブートストラップトークン” を埋め込み、クライアントで安全なトークンに交換して保管する手があります(生アクセストークンをそのまま HTML に書き出さないのが肝)。

プリレンダリングで状態を埋め込み/クライアントで回収

@* App.razor(抜粋) *@
@inject PersistentComponentState AppState
@inject IHttpContextAccessor HttpContextAccessor

@code {
private PersistingComponentStateSubscription? _sub;
protected override void OnInitialized()
{
_sub = AppState.RegisterOnPersisting(() =>
{
var ctx = HttpContextAccessor.HttpContext;
// Cookie から「ブートストラップ用の短命トークン」を取り出す(生アクセストークンは避ける)
if (ctx?.Request.Cookies.TryGetValue("bootstrap_token", out var bootstrap) == true)
{
AppState.PersistAsJson("bootstrap", new { token = bootstrap });
}
return Task.CompletedTask;
});
}


public void Dispose() => _sub?.Dispose();


} 
@* クライアント初回レンダー時に受け取り、交換 API を叩いて安全なトークンを取得 *@
@inject PersistentComponentState AppState
@inject HttpClient Http
@inject IJSRuntime JS

@code {
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;

        if (AppState.TryTakeFromJson&lt;BootstrapState&gt;("bootstrap", out var state)
            &amp;&amp; !string.IsNullOrWhiteSpace(state?.Token))
        {
            // 交換用エンドポイントへ(CORS / CSRF 対策は必須)
            var result = await Http.PostAsJsonAsync("/api/token/exchange", new { state.Token });
            var secure = await result.Content.ReadFromJsonAsync&lt;ExchangeResponse&gt;();
            if (!string.IsNullOrWhiteSpace(secure?.AccessToken))
            {
                // localStorage へ保存(XSS リスクに注意)
                await JS.InvokeVoidAsync("localStorage.setItem", "access_token", secure.AccessToken);
            }
        }
    }

    private sealed record BootstrapState(string Token);
    private sealed record ExchangeResponse(string AccessToken, DateTimeOffset ExpiresAt);
}

重要: localStorage へのトークン保存は XSS リスクがあるため、短命・回転・最小権限・必ず HTTPS・Content Security Policy・徹底した入力サニタイズ を前提にしてください。可能なら OIDC パッケージを使いましょう。

WASM で Cookie セッション API を呼びたいとき

API 側が Cookie セッション認証で、ブラウザに Cookie を付けて送らせたいだけなら、WebAssemblyHttpHandler の credentials を include にします(クロスサイトは CORS + AllowCredentials 必須)。

// WASM で Cookie を同送したいケース
builder.Services.AddScoped(sp =&gt;
{
    var handler = new WebAssemblyHttpHandler
    {
        // Fetch API の credentials=include 相当
        DefaultCredentials = FetchCredentialsOption.Include
    };
    return new HttpClient(handler) { BaseAddress = new Uri("https://api.example.com/") };
});

認証状態の復元:Custom AuthenticationStateProvider

UI の表示制御(<AuthorizeView> など)は AuthenticationState に依存します。WASM 側では保存トークンからクレームを組み立てる実装例が必要です。

public sealed class TokenAuthenticationStateProvider : AuthenticationStateProvider
{
    private readonly IJSRuntime _js;
    private ClaimsPrincipal _anonymous = new(new ClaimsIdentity());


public TokenAuthenticationStateProvider(IJSRuntime js) => _js = js;

public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
    var token = await _js.InvokeAsync<string?>("localStorage.getItem", "access_token");
    if (string.IsNullOrWhiteSpace(token))
        return new AuthenticationState(_anonymous);

    // 例:JWT を読み取り最低限のクレームを復元(検証はサーバー側でも必須)
    var claims = JwtParser.ToClaims(token); // 実装は省略
    var identity = new ClaimsIdentity(claims, "Bearer");
    return new AuthenticationState(new ClaimsPrincipal(identity));
}

public void NotifyUserStateChanged() => NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());


} 
// Program.cs
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped&lt;AuthenticationStateProvider, TokenAuthenticationStateProvider&gt;();

二系統の HttpClient を使い分ける(注入時に選択)

コンポーネントからは「今どちらで動いているか」を見て、適切なクライアントを選びます。

@inject IHttpClientFactory Factory

@code {
private HttpClient _api = default!;


protected override void OnInitialized()
{
    // WASM なら BrowserApi、サーバーなら ServerApi を使う
    _api = OperatingSystem.IsBrowser()
        ? Factory.CreateClient("BrowserApi")
        : Factory.CreateClient("ServerApi");
}


} 

本番環境チェックリスト(Auto が WASM 側に倒れる要因の除去)

項目確認ポイント設定ヒント
WebSocket / SignalRプロキシ・LB 経由で Upgrade が通るかNGINX: proxy_http_version 1.1、Upgrade と Connection: upgrade ヘッダを転送
CORSブラウザ→API の許可オリジン・メソッド・ヘッダ・資格情報AllowCredentials() 使用時はワイルドカード禁止・オリジンを明示
Cookie 属性サブドメインやクロスサイトで送れるかSameSite=None; Secure、ドメイン属性、HTTPS 強制
レンダリングモード本番でも Auto のままか、Server へ固定したいか@rendermode を InteractiveServerRenderMode に切替可
API ベースアドレス開発と本番で同一オリジンかクロスサイトなら CORS/credentials と Cookie 属性の整合をとる
HTTPS/TLS常時 HTTPS かCookie の Secure、HSTS、CSP を徹底

レンダリングモードの明示(必要なら固定)

@* 既定(Auto) *@
<Routes @rendermode="new InteractiveAutoRenderMode()" />

@* 強制的に Server *@


@* 強制的に WASM *@
 

「どこで呼ばれているか」をログで即判別する

public static class LogOrigin
{
    public static void Write(ILogger logger, string where)
        => logger.LogInformation(
            "origin={Where}, isBrowser={IsBrowser}, thread={ThreadId}, trace={TraceId}",
            where,
            OperatingSystem.IsBrowser(),
            Environment.CurrentManagedThreadId,
            System.Diagnostics.Activity.Current?.TraceId.ToString());
}

// DelegatingHandler から
_logger.LogInformation("HTTP sending..."); LogOrigin.Write(_logger, "DelegatingHandler"); 

ログに isBrowser=True が並んでいれば、クライアント側で HttpClient が走っています(=Cookie 読み取りは不可能)。

トラブルパターン早見表

症状原因の目安第一対応
本番だけ HttpContext が nullAuto が WASM に倒れているサーバー用/WASM 用のハンドラを分離。Cookie 読み取りはサーバー専用に限定。
ログイン直後は OK、遷移後に 401/403プリレンダリング後に WASM へ切替AuthenticationStateProvider で状態を復元し、WASM 用 HttpClient にトークンを自動注入。
開発は OK、本番のみ 401CORS / Cookie 属性の不整合AllowCredentials + 明示 Origins、SameSite=None; Secure、DefaultCredentials=Include を確認。
たまにだけ WASM 側へ倒れるWebSocket が不安定プロキシ/LB の keep-alive と Upgrade 設定を安定化。タイムアウト値も見直し。

セキュリティと設計のベストプラクティス

  • WASM では Cookie に頼らない:HttpOnly Cookie を読めません。必要なら 同送(credentials=include)に切り替えるか、OIDC でトークンを取得。
  • localStorage の XSS リスク:保存は最小限・短命・回転・CSP 導入。可能ならメモリ保持(ページリロードで失われるがより安全)。
  • 権限の最小化:クライアントに渡すのはリフレッシュ不可の短命アクセストークン、もしくは交換用の一時トークン。
  • サーバー側での最終検証:WASM 由来のトークンは常にサーバーで検証(署名・期限・オーディエンス)。
  • 役割分離:ServerApi と BrowserApi の二系統を常に維持し、実装の混入を防ぐ。

最後に:本番で“突然”壊れない Blazor 設計へ

本番でだけ HttpContext が null になるのは、壊れているのではなく Blazor(.NET 8)の仕様が正しく発動している結果です。プリレンダリング中だけが HttpContext の居場所であり、対話フェーズでは Server でも WASM でも AuthenticationState と HttpClient の設計が鍵を握ります。

本記事のように「サーバー専用の Cookie パス」と「クライアント専用のトークンパス」を分離し、必要ならレンダリングモードを明示。インフラ(SignalR/CORS/Cookie/HTTPS)の健全性を揃えれば、開発/本番の差分はほぼ消えます。
“HttpContext が null 問題” は、設計の地ならしで完全に回避できます。

実装断片(まとめ)

  • Server 用:CookieTokenMiddleware → AccessTokenHolder → ServerCookieAuthorizationHandler。
  • WASM 用:AuthorizationMessageHandler(推奨:OIDC の IAccessTokenProvider)。
    やむを得ない場合だけ、ブートストラップトークン → 交換 API → localStorage。
  • クライアント選択:OperatingSystem.IsBrowser() で BrowserApi/ServerApi を使い分け。
  • レンダリングモード:@rendermode で Auto/Server/WASM を明示可。
  • 運用:WebSocket・CORS・Cookie 属性・HTTPS・CSP を定期点検。詳細ログで実行場所を可視化。

付録:最小構成の Program.cs(要点のみ)

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents(); // Auto で WASM も使用できる状態にする

// 認証(例:Cookie)や OIDC の設定は省略
builder.Services.AddAuthorization();

// ServerApi(サーバー専用)
builder.Services.AddScoped();
builder.Services.AddHttpClient("ServerApi", c => c.BaseAddress = new Uri("[https://api.example.com/](https://api.example.com/)"))
.AddHttpMessageHandler();

// BrowserApi(WASM 専用)
builder.Services.AddScoped();
builder.Services.AddHttpClient("BrowserApi", c => c.BaseAddress = new Uri("[https://api.example.com/](https://api.example.com/)"))
.AddHttpMessageHandler();

var app = builder.Build();

app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();

app.UseMiddleware();

app.MapRazorComponents()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode();

app.Run(); 

付録:CORS(Cookie/資格情報を伴うクロスサイト呼び出し)

builder.Services.AddCors(options =>
{
    options.AddPolicy("ApiWithCredentials", p => p
        .WithOrigins("https://app.example.com") // 明示
        .AllowAnyHeader()
        .AllowAnyMethod()
        .AllowCredentials());
});

app.UseCors("ApiWithCredentials"); 

Cookie 送信が必要なクロスサイトは AllowCredentials + オリジン明示 が鉄則です。

付録:NGINX の WebSocket 転送(概略)

# /location ないし / で
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;

Upgrade ヘッダを正しく通さないと Interactive Server へ移行できず、Auto が WASM に倒れ続けます。

要点の再掲

  • HttpContext が使えるのは SSR 中だけ。対話フェーズは使えない。
  • Auto は本番で WASM 側に倒れがち。Cookie 依存のハンドラは崩れる。
  • サーバーとクライアントのパイプを分け、役割を明確化する。
  • WASM は OIDC or 交換式の短命トークン。Cookie は「同送」するかサーバーで読む。
  • インフラ(SignalR/CORS/Cookie/HTTPS)を合わせ込み、ログで実行場所を可視化。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次