ASP.NET Core Minimal APIでセッション・クッキーが保持されない原因とWebhook受信(Cognito Forms対応)

ASP.NET Core Minimal APIで/webhookにセッションやクッキーを設定したのに、別エンドポイントで値が引き継がれずnullになる…。この手のトラブルは「そのリクエストを叩いているクライアントが誰か」「クッキーがブラウザーに保存されているか」「Webhookはそもそもセッション前提ではないか」を押さえるだけで一気に解決します。再現確認の方法から、Cognito FormsのJSON Webhook受信までまとめて解説します。

目次

よくある状況:/webhookでセットしたはずなのに次のページでnull

Minimal API(Program.cs 1ファイル構成)で /webhook を作り、受信時に次のような処理をしているケースを想定します。

  • context.Session.SetString("YourSessionKey", "YourSessionValue") でセッションに値を保存
  • Response.Cookies.Append("myKey", "myValue", options) でクッキーを発行

ところが、同じアプリ内の別エンドポイント(例:/status や /another-page)で Session.GetString をすると毎回null。Postmanではレスポンスヘッダーに Set-Cookie が見えるのに、ブラウザーの開発者ツールではクッキーが見えず、当然セッションも続かない…という状態です。

結論:セッションは「セッションID入りクッキー」を返してもらって成立する

ASP.NET Coreのセッションは、サーバー側に値を保持する仕組みである一方、「どのセッションか」を識別するためのセッションIDはクッキーでやり取りします。流れはシンプルです。

タイミングサーバー側で起きることクライアント側で必要なこと
初回リクエストセッションIDを生成し、レスポンスにSet-Cookieで返す(例:.AspNetCore.Session)受け取ったクッキーを保存する
2回目以降リクエストのCookieからセッションIDを読み、同じセッション領域を参照するリクエストヘッダーに同じクッキーを付けて送る

つまり、次のリクエストでセッション値を読みたいなら「次のリクエストを送るクライアントが、前回のセッション用クッキーを持っている」必要があります。ここが崩れると、サーバー側では毎回「別セッション」と判断され、GetStringはnullになります。

「Postmanでは見えるのにブラウザーでは見えない」最大の理由

原因の筆頭はこれです。

Postman(または外部サービス)が/webhookを叩いているだけで、ブラウザーは/webhookに一度もアクセスしていない。

この場合、/webhookのレスポンスに含まれるCookieは「Postmanの中」には保存されますが、ブラウザーには届きません。ブラウザーが受け取っていない以上、開発者ツールに出ることもありませんし、別エンドポイントにアクセスしてもCookieが付かないのでセッションは引き継がれません。

もう一段深掘り:クッキーが保存されない/送られない典型パターン

「/webhookをブラウザーから叩いたつもりなのに、やっぱりクッキーが見えない」場合は、次のズレを疑うのが近道です。

症状よくある原因チェック/対策
開発者ツールにクッキーが出ないSecure属性付きなのにHTTPでアクセスしているローカルはHTTPSで確認。HTTPならSecure=false(本番はHTTPS前提)
別エンドポイントでクッキーが送られないCookieのPathが狭い(例:/webhookに限定)アプリ全体で使うならPath="/"にする
同じアプリなのにセッションが続かないlocalhostと127.0.0.1など、アクセスしているホスト名が違うブラウザーとPostman(外部サービス)で同一ホストに統一する
JavaScriptのfetchで呼ぶとセッションが続かない別オリジン呼び出しでCookieが送られていない(CORS/credentials/SameSite)credentials: 'include'、サーバー側CORSでAllowCredentials、CookieはSameSite=None+Secure

特にSameSite=Noneを付ける場合、CookieはSecureも必須という仕様になっています。クロスサイトでCookieを使う設計にするなら、この組み合わせは避けて通れません。

Minimal APIでのセッション設定:Program.csの最小テンプレ

Minimal APIでセッションを使うときは、基本的に「キャッシュ(セッションストア)」「セッション登録」「セッションミドルウェア」をセットで用意します。以下はローカル検証向けのインメモリ構成です(複数台構成や再起動耐性が必要ならRedisなどに置き換えます)。

using Microsoft.AspNetCore.Http;
using System.Text.Json;

var builder = WebApplication.CreateBuilder(args);

// 1) セッションの保存先(まずはメモリでOK)
builder.Services.AddDistributedMemoryCache();

// 2) セッションの登録
builder.Services.AddSession(options =>
{
    // Cookie名はわかりやすく変えておくとデバッグが楽です
    options.Cookie.Name = ".MyApp.Session";
    options.IdleTimeout = TimeSpan.FromMinutes(20);

    // セキュリティと同意ポリシー
    options.Cookie.HttpOnly = true;
    options.Cookie.IsEssential = true;

    // クロスサイト要件がないなら Lax が無難(Noneを使うとSecure必須)
    options.Cookie.SameSite = SameSiteMode.Lax;

    // HTTPSならSecure、HTTPなら非Secure(ローカルHTTP検証が必要な場合の保険)
    options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
});

var app = builder.Build();

app.UseHttpsRedirection();

// 3) セッションミドルウェア(エンドポイント処理より前に)
app.UseSession();

// 動作確認用
app.MapGet("/", () => "OK");

app.Run();

Minimal APIでは、エンドポイントがある場合にUseRoutingやUseEndpointsが自動で追加されるなど、パイプラインが一定のルールで組み立てられます。ミドルウェアの追加順序が結果に影響するため、セッションのように「リクエスト前後で処理が必要」なものは早めに登録しておくのが安全です。

ブラウザーで確実に確認する:セッション初期化用のGETを用意する

Webhookは基本的にPOSTで、ブラウザーのURL直打ち検証と相性が悪いです。そこで、原因切り分け用に「ブラウザーが確実に叩くGET」を一つ用意すると、話が早くなります。

app.MapGet("/init-session", (HttpContext context) =>
{
    // セッションに値を入れる(ここでセッションCookieが発行される)
    context.Session.SetString("YourSessionKey", "YourSessionValue");

    // 独自Cookieも同時に発行して挙動を見やすくする
    context.Response.Cookies.Append("myKey", "myValue", new CookieOptions
    {
        Path = "/",
        Expires = DateTimeOffset.UtcNow.AddDays(7),
        HttpOnly = true,
        Secure = context.Request.IsHttps,
        SameSite = SameSiteMode.Lax
    });

    return Results.Text("Session/Cookie initialized.");
});

app.MapGet("/check-session", (HttpContext context) =>
{
    var sessionValue = context.Session.GetString("YourSessionKey");
    var myCookie = context.Request.Cookies["myKey"];

    return Results.Json(new
    {
        sessionValue,
        myCookie,
        hasSessionCookie = context.Request.Cookies.ContainsKey(".MyApp.Session")
    });
});

確認手順は次の通りです。

  1. ブラウザーで/init-sessionにアクセス
  2. 開発者ツール(Application/Storage → Cookies)で.MyApp.SessionとmyKeyが保存されているか確認
  3. 同じタブで/check-sessionにアクセスして、sessionValueが返るか確認

この時点でセッションが引き継がれないなら、Cookie属性(Secure/SameSite/Path/ホスト名)の問題が濃厚です。逆にここが通るなら、「/webhookを叩いているのがブラウザーではない」という本質に戻って設計を見直すべきです。

/webhookでセッションを使いたくなる気持ちと、現実的な落としどころ

「Webhookが届いたことを、次のページで確認したい」目的でセッションに書きたくなることがあります。しかしWebhookはサーバー→サーバーの通知であり、送信元は基本的に“ブラウザーのようにクッキーを保持して再送”しません。つまり、Webhookでセットしたセッションを、あなたのブラウザーが後から読む、という発想が噛み合いません。

そこで、Webhook受信の「事実」を残す方法は、セッションではなくサーバー側ストレージに寄せるのが定石です。

やりたいことセッション/クッキーでやるとおすすめの実装
Webhookが届いたか確認したい送信元がCookieを持たないため、確認できない/不安定DBに受信ログを保存し、管理画面で表示
重複通知を防ぎたいセッションは送信元単位にならず意味が薄いpayloadのIDや日時をキーに冪等(idempotent)に処理
失敗時に再試行したいセッションではリトライ制御ができないキューに積んで非同期処理、失敗時にリトライ

Postmanで「同じセッション」を再現する方法

Postmanで複数回叩いて「前回セットしたセッションが次回も読める」状態を作りたい場合は、ブラウザーと同じくCookieの持ち回りが必要です。

  • 1回目のレスポンスに含まれるSet-Cookie(セッションID)を、2回目以降のリクエストでも送る
  • PostmanのCookie管理(Cookie Jar)を使うと、自動で保持・送信してくれる

ここで保持できているのは「Postmanのセッション」であり、ブラウザーのセッションとは別物です。ブラウザーでセッションを読みたいなら、ブラウザー自身にCookieを配布する必要があります。

WebhookのJSONをMinimal APIで受け取る基本

外部サービス(例:Cognito Forms)がWebhookとしてJSONをPOSTしてくる場合、Minimal API側は「JSONをボディから受け取る」形にします。Cognito Formsは設定で「Post JSON Data to a Website」を有効化し、エントリー作成/更新/削除ごとに指定URLへJSONをPOSTできます。

まずは“受信できているか”を最短で確認する

最初はモデルを固める前に、生のJSONをそのまま受け取ってログに残すのが安全です。フォーム側のフィールド構成が変わるとJSONも変わりやすいからです。

using System.Text.Json;

app.MapPost("/webhook", async (HttpContext context, ILoggerFactory loggerFactory) =>
{
    var logger = loggerFactory.CreateLogger("Webhook");

    // 受信JSONを生でパース(モデルが未確定でも壊れにくい)
    using var doc = await JsonDocument.ParseAsync(context.Request.Body);

    // 例:全体を文字列化してログ(本番では個人情報に注意)
    logger.LogInformation("Webhook received: {Json}", doc.RootElement.ToString());

    return Results.Ok(new { ok = true });
});

受信確認だけならこれで十分です。次に「必要な項目だけ取り出す」「DBに保存する」「重複を弾く」へ進めます。

項目がある程度わかってきたら、型で受ける

フィールド名が固定できるなら、C#クラス(またはrecord)を作ってそのまま受けられます。Minimal APIは複合型パラメータをボディからバインドするため、(Payload payload)の形が最短です。

public sealed class CognitoWebhookPayload
{
    // 例:ドキュメントの例に合わせたプロパティ(実際のキーに合わせて調整)
    public string? id { get; set; }
    public string? email { get; set; }
    public string? name { get; set; }
    public DateTimeOffset? date_created { get; set; }
}

app.MapPost("/webhook-typed", (CognitoWebhookPayload payload, ILoggerFactory loggerFactory) =>
{
    var logger = loggerFactory.CreateLogger("Webhook");
    logger.LogInformation("Entry id={Id}, email={Email}", payload.id, payload.email);

    return Results.Ok("OK");
});

ただし、フォームはフィールド追加・名称変更が起きがちです。その場合は、未知プロパティを吸収できる設計が強いです。

フィールドが可変なら JsonExtensionData で“全部受ける”

Cognito FormsはDeveloper ModeでJSONのキー名(JSON Names)をカスタマイズできます。フォームのラベルが日本語やスペースを含む場合でも、キー名をAPI向けに整えることが可能です。

それでもフィールドが増減する可能性があるなら、[JsonExtensionData]で「未知プロパティを辞書に集める」方式が便利です。

using System.Text.Json;
using System.Text.Json.Serialization;

public sealed class CognitoWebhookFlexiblePayload
{
    // 代表的な固定項目だけ先に定義
    [JsonPropertyName("id")]
    public string? Id { get; set; }

    [JsonPropertyName("date_created")]
    public DateTimeOffset? DateCreated { get; set; }

    // それ以外のフィールドはすべてここへ
    [JsonExtensionData]
    public Dictionary<string, JsonElement>? Fields { get; set; }
}

app.MapPost("/webhook-flex", (CognitoWebhookFlexiblePayload payload) =>
{
    // 例:任意フィールドを取り出す
    if (payload.Fields is not null && payload.Fields.TryGetValue("CompanyName", out var company))
    {
        var companyName = company.GetString();
        // DB保存など
    }

    return Results.Ok();
});

この方法なら「新しいフィールドが増えたら即エラーで落ちる」を避けられます。Webhook受信は止まると業務影響が出やすいので、受信側は“壊れにくい”ほうが運用が楽です。

Webhook運用で重要:リトライされる前提で作る

Webhookはネットワーク状況によって失敗することがあります。Cognito Formsの場合、4xx/5xxを返すと最大15回・72時間にわたり再送するリトライ機構があります(ただし404/410/413はリトライ対象外)。

この前提を踏まえて、受信側は次の2点を必ず意識します。

  • 冪等性(idempotency):同じ通知が複数回届いても二重登録しない(IDや送信日時をキーに重複排除)
  • 素早く200を返す:重い処理はキュー/バックグラウンドに逃がし、受信自体は短時間で成功させる

ファイルURLが含まれる場合の注意点

Cognito FormsのWebhookで送られるファイルリンク(アップロードや生成ドキュメント)は有効期限が短く、概ね30分程度で無効になると案内されています。ファイルが必要なら、受信後すぐにダウンロードしてストレージへ退避するフローにしておくと安全です。

「受信したこと」をブラウザーで見たい場合の実装例

Webhook受信を管理画面で可視化したいなら、セッションではなくDB(または一時的にメモリ)へ“受信ログ”を保存し、ブラウザーはそれを読むだけにします。最小構成のイメージは次の通りです。

// 例:最小の受信ログ(本番はDBに置き換え推奨)
var received = new List<(DateTimeOffset at, string raw)>();

app.MapPost("/webhook", async (HttpContext ctx) =>
{
    using var doc = await JsonDocument.ParseAsync(ctx.Request.Body);
    received.Add((DateTimeOffset.UtcNow, doc.RootElement.ToString()));
    return Results.Ok();
});

app.MapGet("/webhook-status", () =>
{
    var last = received.LastOrDefault();
    return Results.Json(new
    {
        count = received.Count,
        lastReceivedAtUtc = last.at == default ? null : last.at,
        lastPayloadPreview = string.IsNullOrEmpty(last.raw) ? null : last.raw[..Math.Min(200, last.raw.Length)]
    });
});

ポイントは「Webhook受信」と「ブラウザー表示」を完全に分けることです。これならWebhookがどこから来ても記録され、ブラウザーは常に同じ場所(DB/ログ)を見に行けばよくなります。

セッション/クッキーを使うべき場面・使わないほうがいい場面

場面セッション/クッキーおすすめ
同一ブラウザーでの画面遷移(ログイン、ウィザード、カート)相性が良いセッション/認証Cookieを使う
Webhookなどサーバー間通知相性が悪いDB保存・冪等処理・署名検証など“ステートレス寄り”に
SPAが別ドメインAPIを叩く設定が難しい(SameSite/CORS/CSRF)可能ならトークン認証(JWT等)。Cookie運用なら要件を明確化

トラブルシューティングチェックリスト

  • /webhookを叩いているのは誰か:ブラウザーか、Postmanか、外部サービスか
  • クッキーはどこに保存されたか:PostmanのCookie Jarか、ブラウザーのCookieストレージか
  • ホスト名は一致しているか:localhostと127.0.0.1の混在、サブドメイン違い
  • Secure/SameSite/Path:HTTPでSecureを付けていないか、Pathが狭すぎないか
  • Webhookはリトライされる:二重処理にならない設計か、200を返せているか

まとめ

  • セッションが別エンドポイントでnullになる多くの原因は、次のリクエストで同じセッションCookieが返ってきていないこと
  • Postmanで見えるCookieはPostman内に保存されるだけで、ブラウザーが/webhookを叩いていなければブラウザーには配布されない
  • Webhookはセッション前提ではないため、受信確認や重複排除はDB保存+冪等設計に寄せる
  • Cognito FormsのWebhookはJSONをPOSTでき、リトライやファイルURLの期限など運用上の注意点があるため、受信側は“壊れにくい”実装が重要

この記事を書いた人

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

コメント

コメントする

目次