Blazor Web App(.NET 8/Interactive Auto)で開発時は動くのに、本番だけ IHttpContextAccessor.HttpContext が null になり認可ヘッダの付与が失敗する――この“あるある”を、レンダリングモード・Cookie・SignalR の観点から分解し、再発しない設計と実装の全体像をコード付きで解説します。
問題の背景と症状の整理
想定される構成は次のとおりです。
- .NET 8 の Blazor Web App。レンダリングモードは Interactive Auto。
- ログイン後、
DelegatingHandler(例:AuthorizationHandler)が Cookieaccess_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 依存のハンドラが崩れます。
なぜ開発は成功し本番で失敗するのか(因果を分解)
- Interactive Auto の特性:
HttpContextは SSR(プリレンダリング)の間だけ有効。対話フェーズでは基本使えません。 - 呼び出し元の違い:開発時はサーバーで
HttpClientが実行されていたが、本番はブラウザ(WASM)で実行。結果としてIHttpContextAccessor.HttpContextは常にnull。 - Cookie 依存設計:WASM からは HttpOnly Cookie を読めず、SameSite 設定やクロスサイト条件も絡んで
DelegatingHandlerが破綻。 - SignalR/WebSocket 未接続:本番で回線が張れず Server では動かない → Auto が WASM 側に倒れる → さらに
HttpContext不在が顕在化。
下図は開発(左)と本番(右)の違いを示すイメージです。
開発(動く) 本番(落ちる)
[SSR]-->[Interactive Server] [SSR]-->[Interactive Auto -> WASM]
| | | |
HttpContext |(イベント時は無し) HttpContext WASM のため無し
Cookie読取可 | Cookie読取可 Cookie読取不可
↓ ↓ ↓ ↓
DelegatingHandler で付与 DelegatingHandler は Cookie に触れず失敗
結論(再発しない設計指針)
まず「Blazor のクライアントから Cookie を直接読む前提を捨てる」ことが出発点です。サーバー用とクライアント用のパイプを分け、役割ごとに最小限の責務を与えます。
| 手順 | 対応内容 | ポイント |
|---|---|---|
| 1 | HttpContext 依存コードを分離 | Cookie を読むハンドラは「サーバー専用」。WASM 側 HttpClient には適用しない。 |
| 2 | プリレンダリングで必要最小限の状態を永続化 | PersistentComponentState で “ブートストラップ用” の短命トークンやフラグを渡す。 |
| 3 | Custom AuthenticationStateProvider | WASM では 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<AccessTokenHolder>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddHttpClient("ServerApi", c =>
{
c.BaseAddress = new Uri("https://api.example.com/");
})
.AddHttpMessageHandler<ServerCookieAuthorizationHandler>();
app.UseMiddleware<CookieTokenMiddleware>();
クライアント(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<ApiAuthorizationMessageHandler>();
builder.Services.AddHttpClient("BrowserApi", c =>
{
c.BaseAddress = new Uri("https://api.example.com/");
})
.AddHttpMessageHandler<ApiAuthorizationMessageHandler>();
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<BootstrapState>("bootstrap", out var state)
&& !string.IsNullOrWhiteSpace(state?.Token))
{
// 交換用エンドポイントへ(CORS / CSRF 対策は必須)
var result = await Http.PostAsJsonAsync("/api/token/exchange", new { state.Token });
var secure = await result.Content.ReadFromJsonAsync<ExchangeResponse>();
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 =>
{
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<AuthenticationStateProvider, TokenAuthenticationStateProvider>();
二系統の 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 が null | Auto が WASM に倒れている | サーバー用/WASM 用のハンドラを分離。Cookie 読み取りはサーバー専用に限定。 |
| ログイン直後は OK、遷移後に 401/403 | プリレンダリング後に WASM へ切替 | AuthenticationStateProvider で状態を復元し、WASM 用 HttpClient にトークンを自動注入。 |
| 開発は OK、本番のみ 401 | CORS / 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)を合わせ込み、ログで実行場所を可視化。

コメント