VB.NET WebForms の Response.Cookies("Name")("SubKey") を C#(WebForms)や Blazor(Server / WASM)へ移行するとき、まず押さえるべきは「サブキー」はあくまで 1 個の Cookie の中に格納された URL エンコード済みのクエリ文字列であるという事実です。本記事では、その内部表現から安全な移行パターン、落とし穴の回避策までを具体的なコードで徹底解説します。
結論(最短ルート)
- VB.NET / ASP.NET WebForms の サブキー付き Cookieは、送出時に
StaffInfoCookie=ClinicID=...&JobID=...のような URL エンコード済みクエリ文字列に連結され、Set-Cookieヘッダーで 1 つの Cookie として送られます。 - 複数 Cookie や属性(
Path、Expiresなど)を区切るのは セミコロンであり、サブキーをセミコロンで区切ってはいません。 - C# WebForms では
var cookie = new HttpCookie("StaffInfoCookie"); cookie["ClinicID"] = ...;と書けます(互換そのまま)。 - Blazor では 直接の Cookie API は C# から使えないため、JS Interop で
document.cookieを操作するか、サーバー側でResponse.Cookies.Appendを使います。 - 移行時は SameSite / Secure / HttpOnly を明示し、4KB 制限・PII を置かない・暗号化/署名などの実務要件を再確認します。
VB.NET WebForms の内部(なぜサブキーが使えるのか)
VB.NET / ASP.NET WebForms の次のコードは、
Response.Cookies("StaffInfoCookie")("ClinicID") = ddlClinic.SelectedValue
Response.Cookies("StaffInfoCookie")("JobID") = ddlJob.SelectedValue
Response.Cookies("StaffInfoCookie").Expires = DateTime.Now.AddDays(366)
HttpCookie オブジェクトの Values(NameValueCollection)に ClinicID=value と JobID=value のペアが積み上がるだけです。HTTP 応答では 1 行の Set-Cookie として送られ、値部分は URL エンコード済みのクエリ文字列になります。
Set-Cookie: StaffInfoCookie=ClinicID=value1&JobID=value2; Path=/; Expires=Wed, 05 Nov 2025 12:34:56 GMT
ここでの要点:
- サブキーの区切りはアンパサンド(
&)です。 - Cookie 属性や複数 Cookie の区切りはセミコロン(
;)です。 - 値は URL エンコードされます(空白、記号、日本語などが安全に転送されるようにするため)。
C# WebForms での置き換え(等価コード)
var cookie = new HttpCookie("StaffInfoCookie");
cookie["ClinicID"] = ddlClinic.SelectedValue;
cookie["JobID"] = ddlJob.SelectedValue;
cookie.Expires = DateTime.Now.AddDays(366);
cookie.Path = "/"; // 既定で "/" ですが、明示を推奨
// セキュリティ属性(必要に応じて)
cookie.HttpOnly = true; // JS から読ませない
cookie.Secure = true; // HTTPS 必須
Response.Cookies.Add(cookie);
読み出しは次の通りです。
var cookie = Request.Cookies["StaffInfoCookie"];
var clinicId = cookie?["ClinicID"];
var jobId = cookie?["JobID"];
Blazor での対応(Server / WASM)
Blazor の C# ランタイムはブラウザの document.cookie に直接アクセスできません。主な選択肢は次の 2 つです。
- JavaScript Interop で
document.cookieを操作(Blazor Server / WASM 共通)。 - サーバー側で Cookie を出し分け(Blazor Server・ASP.NET Core のミドルウェア/エンドポイントで
Response.Cookies.Append)。
(推奨)JS Interop ラッパーで安全に Cookie を読む/書く
まず、wwwroot/js/cookie.js を用意します(ES モジュール)。
// wwwroot/js/cookie.js
export function setCookie(name, value, options = {}) {
const {
path = "/",
maxAge, // 秒
expires, // Date または UTC 文字列
sameSite, // "Lax" | "Strict" | "None"
secure, // boolean
httpOnly // JS からは付与できない(ヘッダーのみ)
} = options;
let cookie = `${encodeURIComponent(name)}=${value}`;
if (path) cookie += `; path=${path}`;
if (Number.isFinite(maxAge)) cookie += `; max-age=${Math.floor(maxAge)}`;
if (expires) {
const d = expires instanceof Date ? expires.toUTCString() : expires;
cookie += `; expires=${d}`;
}
if (sameSite) cookie += `; samesite=${sameSite}`;
if (secure) cookie += `; secure`;
// httpOnly はクライアント JS では設定不可
document.cookie = cookie;
}
export function getCookie(name) {
const encoded = encodeURIComponent(name) + "=";
const parts = document.cookie.split(/;\s*/);
for (const p of parts) {
if (p.startsWith(encoded)) {
return p.substring(encoded.length);
}
}
return null;
}
export function deleteCookie(name, path = "/") {
document.cookie = `${encodeURIComponent(name)}=; path=${path}; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
}
Blazor 側で呼び出すサービス(DI 登録可能)を用意します。
// CookieInterop.cs
using Microsoft.JSInterop;
public sealed class CookieInterop : IAsyncDisposable
{
private readonly Lazy> _moduleTask;
public CookieInterop(IJSRuntime js)
=> _moduleTask = new(() => js.InvokeAsync<IJSObjectReference>("import", "./js/cookie.js").AsTask());
public async ValueTask SetAsync(string name, string value,
string path = "/", TimeSpan? maxAge = null, DateTimeOffset? expires = null,
string? sameSite = "Lax", bool secure = true)
{
var module = await _moduleTask.Value;
await module.InvokeVoidAsync("setCookie", name, value, new
{
path,
maxAge = maxAge?.TotalSeconds,
expires = expires?.UtcDateTime.ToUniversalTime().ToString("R"),
sameSite,
secure
});
}
public async ValueTask<string?> GetAsync(string name)
{
var module = await _moduleTask.Value;
return await module.InvokeAsync<string?>("getCookie", name);
}
public async ValueTask DeleteAsync(string name, string path = "/")
{
var module = await _moduleTask.Value;
await module.InvokeVoidAsync("deleteCookie", name, path);
}
public async ValueTask DisposeAsync()
{
if (_moduleTask.IsValueCreated)
{
var module = await _moduleTask.Value;
await module.DisposeAsync();
}
}
}
DI 登録と使用例:
// Program.cs(Blazor WASM/Server いずれも)
builder.Services.AddScoped<CookieInterop>();
@inject CookieInterop Cookie
@code {
private async Task SaveStaffInfoAsync(string clinicId, string jobId)
{
// 「サブキー」互換の値を連結(後述のユーティリティで本来は生成)
var value = $"ClinicID={CookieSubKey.EncodeComponent(clinicId)}&JobID={CookieSubKey.EncodeComponent(jobId)}";
await Cookie.SetAsync("StaffInfoCookie", value,
path: "/", maxAge: TimeSpan.FromDays(366),
sameSite: "Lax", secure: true);
}
private async Task<string?> LoadClinicIdAsync()
{
var raw = await Cookie.GetAsync("StaffInfoCookie");
var dict = CookieSubKey.Parse(raw);
return dict.TryGetValue("ClinicID", out var v) ? v : null;
}
}
Blazor Server:サーバーで Set-Cookie を出す(Response.Cookies.Append)
HTTP 応答を発行できる場所(ミニマル API、コントローラ、Razor Pages 等)で Cookie を書き込み、コンポーネントからは HttpClient 経由で呼び出すと堅実です。
// Program.cs(ASP.NET Core)
app.MapPost("/api/staff-cookie", (HttpContext ctx, StaffInfo dto) =>
{
var value = CookieSubKey.Encode(new Dictionary<string, string> {
["ClinicID"] = dto.ClinicID,
["JobID"] = dto.JobID
});
var opt = new CookieOptions {
Path = "/",
Expires = DateTimeOffset.UtcNow.AddDays(366),
HttpOnly = true, // 読取を JS に許可しない場合は true
Secure = true, // HTTPS 本番必須
SameSite = SameSiteMode.Lax // クロスサイト遷移が必要な場合は None + Secure
};
ctx.Response.Cookies.Append("StaffInfoCookie", value, opt);
return Results.Ok();
});
public record StaffInfo(string ClinicID, string JobID);
読み出し(サーバー側):
app.MapGet("/api/staff-cookie", (HttpContext ctx) =>
{
if (!ctx.Request.Cookies.TryGetValue("StaffInfoCookie", out var raw)) return Results.NoContent();
var dict = CookieSubKey.Parse(raw);
return Results.Ok(dict);
});
注意:Blazor Server のコンポーネント内で IHttpContextAccessor を直接使うと、シグナルR 経路では HttpContext が null になり得ます。Cookie の出し入れは「HTTP リクエストに結びつく層(エンドポイント、ミドルウェア)」で実装し、コンポーネントからは API を呼ぶ構成を推奨します。
「サブキー」互換の値を作るユーティリティ(.NET 共通)
VB の HttpCookie.Values に近い表現(key=value&key2=value2)を安全に生成/解析する小さなヘルパーです。空白は + に、その他はパーセントエンコードに統一し、互換性を高めます。
using System.Net;
using System.Text;
public static class CookieSubKey
{
// 1 要素のエンコード(空白-> +、それ以外は %XX)
public static string EncodeComponent(string value)
=> WebUtility.UrlEncode(value).Replace("%20", "+");
public static string Encode(IDictionary<string, string> pairs)
{
var sb = new StringBuilder();
foreach (var kv in pairs)
{
if (sb.Length > 0) sb.Append('&');
sb.Append(EncodeComponent(kv.Key));
sb.Append('=');
sb.Append(EncodeComponent(kv.Value));
}
return sb.ToString();
}
// URL デコード(+ は空白として扱う)
public static string DecodeComponent(string value)
=> WebUtility.UrlDecode(value.Replace("+", "%20"));
public static Dictionary<string, string> Parse(string? value)
{
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrEmpty(value)) return dict;
foreach (var part in value.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var idx = part.IndexOf('=');
if (idx < 0)
{
dict[DecodeComponent(part)] = "";
}
else
{
var k = DecodeComponent(part.Substring(0, idx));
var v = DecodeComponent(part.Substring(idx + 1));
dict[k] = v;
}
}
return dict;
}
}
JavaScript で値を組み立てる場合は、URLSearchParams を使うとシンプルです。
const p = new URLSearchParams();
p.set("ClinicID", clinicId);
p.set("JobID", jobId);
const value = p.toString(); // 例: "ClinicID=CL01&JobID=J01"
セキュリティと運用の実戦チェックリスト
- 4KB 制限:1 Cookie あたり概ね 4KB。大きなデータは入れない(LocalStorage/DB へ)。
- HttpOnly:JS から触らせたくない値(トークン等)は
HttpOnlyを付け、JS で読む設計を避ける。 - Secure:本番は HTTPS 前提。
Secureを付けないと平文送信される。 - SameSite:既定の挙動に頼らず明示する。外部サイトからの遷移・ポストバックが要るなら
SameSite=None; Secure。 - Path / Domain:サブアプリやサブドメインでの共有要件を洗い出し、
PathDomainを設計。 - 寿命管理:
ExpiresまたはMax-Ageを一貫して使用。短期情報はセッション Cookie を検討。 - PII/機密データ:個人情報やセッション・トークンは基本的に Cookie に平文で置かない。置くなら暗号化・署名(サーバー検証)を。
- 二重エンコード:
valueを JSON 化した後に再度 URL エンコードするなど、重複エンコードに注意。 - 文字コード:エンコード/デコードのペアを固定(本稿のユーティリティを共通利用)。
- テスト観点:日本語・スペース・
&・=・+・絵文字などを含むケースで往復テスト。
移行パターン別:設計の指針
| 項目 | VB.NET / C# WebForms | Blazor(WASM/Server) |
|---|---|---|
| Cookie 直接操作 | Response.Cookies / Request.Cookies | JS Interop(document.cookie)または サーバーで Response.Cookies.Append |
| サブキー表現 | cookie["Key"] が key=value&... へ直列化 | 同形式を自前で生成(本稿のユーティリティ/URLSearchParams) |
| セキュリティ属性 | HttpOnly / Secure / SameSite | クライアント JS では HttpOnly 付与不可。必要ならサーバー側で発行 |
| 代替ストレージ | ViewState/Session(用途次第) | ProtectedLocalStorage / SessionStorage、認証 Cookie とクレーム |
| 推奨構成 | 既存資産をそのまま活用 | JS Interop ヘルパー +(必要時)API 経由のサーバー発行 |
よくある疑問と回答
「VB はサブキーをセミコロンで区切っていますか?」
いいえ。サブキーは & で連結された クエリ文字列です。セミコロンは Cookie の属性や複数 Cookie の区切りに使われます。
「旧実装と 全く同じ 直列化にしたい」
本稿の CookieSubKey は空白を + に、その他は %XX にして互換性を高めています。厳密一致が必要なら、移行前に実データで往復テストし、差分が出ないことを確認してください。
「JS から書いた Cookie を HttpOnly にできますか?」
できません。HttpOnly はサーバーが Set-Cookie でしか付与できません。JS から読み書きしたい Cookie は HttpOnly=false で発行する設計にするか、JS は避けサーバーで発行してください。
「SameSite の推奨値は?」
サイト間 POST/リダイレクトで Cookie が必要ないなら Lax を基本に。外部 IdP 連携等でクロスサイトが必須なら None; Secure を明示します。
安全・保守しやすい最終形(おすすめ設計)
- Cookie の要否を再検討:単なる UI の一時保存なら
LocalStorageの方がシンプル。 - 必要なら 1 箇所に集約:CookieSubKey + CookieInterop を「唯一の窓口」として用意。
- セキュリティ方針を先に決める:
HttpOnly/Secure/SameSiteを全体方針で統一。 - Blazor Server では API 経由:Cookie の発行/更新は API(ミニマル API 等)で行い、UI は API を呼ぶ。
- 回帰テスト:旧アプリと新アプリの Cookie 値を比較(日本語・記号・長文)。
- 可観測性:ログに Cookie の サイズと有無(内容は不可)を残し、上限や欠落を検知。
サンプル:旧 WebForms → Blazor への移行小片
旧:WebForms の保存処理
Sub StoreStaffInfoInCookie(clinicId As String, jobId As String)
Response.Cookies("StaffInfoCookie")("ClinicID") = clinicId
Response.Cookies("StaffInfoCookie")("JobID") = jobId
Response.Cookies("StaffInfoCookie").Expires = DateTime.Now.AddDays(366)
Response.Cookies("StaffInfoCookie").HttpOnly = True
Response.Cookies("StaffInfoCookie").Secure = True
End Sub
新:Blazor(WASM/Server 共通、JS Interop 使用)
async Task StoreStaffInfoAsync(string clinicId, string jobId)
{
var pairs = new Dictionary<string,string> {
["ClinicID"] = clinicId,
["JobID"] = jobId
};
var value = CookieSubKey.Encode(pairs);
await Cookie.SetAsync("StaffInfoCookie", value,
path: "/", maxAge: TimeSpan.FromDays(366),
sameSite: "Lax", secure: true);
}
新:Blazor Server(サーバーで HttpOnly を付けて発行)
// UI から呼ぶ
await Http.PostAsJsonAsync("/api/staff-cookie", new { ClinicID = clinicId, JobID = jobId });
この方式なら HttpOnly が付与でき、XSS 経由で JS から読まれるリスクを低減できます(認可が必要ならサーバー API にも認証を掛けます)。
トラブルシューティング
- 値が読めない:
Path(/app配下だけに設定していないか)、Domain(サブドメイン差)、SameSite(クロスサイト)の見直し。 - 値が壊れる:二重エンコード(
%25が増える等)や、+と空白の取り扱いを疑う。 - ブラウザ差:古いブラウザや WebView では
SameSite=Noneの互換性が不完全な場合あり。明示設定と 検証が重要。 - Cookie が付かない:HTTPS 必須の
Secureをローカル HTTP で試していないか。開発時はSecure=falseで検証し、本番で有効化。 - サイズ超過:4KB 近辺で切り捨て。キーを短くし、値は短縮・別ストアへ退避。
実務 Tips(移行をなめらかにする小技)
- 「Cookie の契約」を文章化:名前、サブキー、属性、寿命、用途、責任者を 1 ページに。
- サブキーは最小限:
ClinicIDとJobIDなど識別子だけに絞り、派生情報は都度サーバーで引く。 - 署名付き値:改ざん検知が要る場合は HMAC を付与(例:
data=...&sig=HMAC(data))。 - デバッグ出力:開発ビルドのみ
Consoleやブラウザ開発者ツールでdocument.cookieを確認。 - ヘッダー確認:サーバー側ログに
Set-Cookieの属性(値は出さない)を出し、運用での漏れを早期検知。
まとめ
WebForms の「サブキー付き Cookie」は、実体としては 1 個の Cookie の値に詰め込まれた URL エンコード済みクエリ文字列です。C# WebForms ではそのまま移行でき、Blazor では JS Interop あるいはサーバー発行で再現可能です。移行を機に SameSite/HttpOnly/Secure を明示し、ユーティリティで直列化/復元を一元化すれば、互換性と安全性を両立しつつ将来の変更にも強い構成になります。

コメント