VB.NETのサブキー付きCookieをC#/Blazorへ移行する完全ガイド|ASP.NET WebFormsからASP.NET Coreの実装・SameSite/HttpOnly/Secureまで

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 つです。

  1. JavaScript Interop で document.cookie を操作(Blazor Server / WASM 共通)。
  2. サーバー側で 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) =&gt;
{
    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&lt;string, string&gt; pairs)
{
    var sb = new StringBuilder();
    foreach (var kv in pairs)
    {
        if (sb.Length &gt; 0) sb.Append('&amp;');
        sb.Append(EncodeComponent(kv.Key));
        sb.Append('=');
        sb.Append(EncodeComponent(kv.Value));
    }
    return sb.ToString();
}

// URL デコード(+ は空白として扱う)
public static string DecodeComponent(string value)
    =&gt; WebUtility.UrlDecode(value.Replace("+", "%20"));

public static Dictionary&lt;string, string&gt; Parse(string? value)
{
    var dict = new Dictionary&lt;string, string&gt;(StringComparer.OrdinalIgnoreCase);
    if (string.IsNullOrEmpty(value)) return dict;
    foreach (var part in value.Split('&amp;', StringSplitOptions.RemoveEmptyEntries))
    {
        var idx = part.IndexOf('=');
        if (idx &lt; 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&amp;JobID=J01"

セキュリティと運用の実戦チェックリスト

  • 4KB 制限:1 Cookie あたり概ね 4KB。大きなデータは入れない(LocalStorage/DB へ)。
  • HttpOnly:JS から触らせたくない値(トークン等)は HttpOnly を付け、JS で読む設計を避ける。
  • Secure:本番は HTTPS 前提。Secure を付けないと平文送信される。
  • SameSite:既定の挙動に頼らず明示する。外部サイトからの遷移・ポストバックが要るなら SameSite=None; Secure。
  • Path / Domain:サブアプリやサブドメインでの共有要件を洗い出し、Path Domain を設計。
  • 寿命管理:Expires または Max-Age を一貫して使用。短期情報はセッション Cookie を検討。
  • PII/機密データ:個人情報やセッション・トークンは基本的に Cookie に平文で置かない。置くなら暗号化・署名(サーバー検証)を。
  • 二重エンコード:value を JSON 化した後に再度 URL エンコードするなど、重複エンコードに注意。
  • 文字コード:エンコード/デコードのペアを固定(本稿のユーティリティを共通利用)。
  • テスト観点:日本語・スペース・&・=・+・絵文字などを含むケースで往復テスト。

移行パターン別:設計の指針

項目VB.NET / C# WebFormsBlazor(WASM/Server)
Cookie 直接操作Response.Cookies / Request.CookiesJS 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 を明示します。

安全・保守しやすい最終形(おすすめ設計)

  1. Cookie の要否を再検討:単なる UI の一時保存なら LocalStorage の方がシンプル。
  2. 必要なら 1 箇所に集約:CookieSubKey + CookieInterop を「唯一の窓口」として用意。
  3. セキュリティ方針を先に決める:HttpOnly/Secure/SameSite を全体方針で統一。
  4. Blazor Server では API 経由:Cookie の発行/更新は API(ミニマル API 等)で行い、UI は API を呼ぶ。
  5. 回帰テスト:旧アプリと新アプリの Cookie 値を比較(日本語・記号・長文)。
  6. 可観測性:ログに 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&lt;string,string&gt; {
        ["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 を明示し、ユーティリティで直列化/復元を一元化すれば、互換性と安全性を両立しつつ将来の変更にも強い構成になります。

この記事を書いた人

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

コメント

コメントする

目次