ASP.NET CoreのMinimal APIをIIS Express上で動かしていると、GETは成功しているのにPOSTだけCORSエラーで落ちる――開発現場でとても起きやすい落とし穴です。本記事は「なぜそうなるのか」をHTTPレベルから分解し、Windows認証×IIS Express特有のハマりどころまで含めて、最短で直すための手順と再発防止策を体系的にまとめました。貼り付けて動く最小コードも用意しています。
ASP.NET Core Minimal APIで「POSTだけCORSでブロック」になるときの原因と対処
症状
- Visual Studio 2022 + IIS Express で開発。
- Windows 認証(Negotiate)が 有効。
- GET は成功、POST は失敗。コンソールには次のエラー:
Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present… AddCorsとUseCorsは設定済みのつもり。
結論(最短の対処フロー)
まずは下表の順に確認・修正してください。ほとんどのケースはこれだけで復旧します。
| 手順 | 内容 | 補足 |
|---|---|---|
| 1 | 具体的なオリジンでCORSポリシーを定義builder.Services.AddCors(o => o.AddPolicy("FrontEnd", p => p.WithOrigins("https://frontend.example.com", "https://localhost:5173") .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials())); | AllowAnyOrigin() と AllowCredentials() は併用不可。必ず具体的なOriginを列挙。 |
| 2 | ミドルウェアの順序を修正app.UseCors("FrontEnd"); // ① CORS(認証より前) app.UseAuthentication(); // ② 認証 app.UseAuthorization(); // ③ 認可 | プリフライト(OPTIONS)は認証前に許可しないと401/403で落ちる。 |
| 3 | OPTIONSに余計なハンドラを置かない Minimal APIのCORSは AllowAnyHeader/Method があればプリフライトに自動応答。手動でMapMethods(..., "OPTIONS", ...)しているなら削除かCORSの後段へ。 | 独自ハンドラが先に動くとCORSヘッダーが付かない。 |
| 4 | 資格情報を送る(フロントエンド)fetch(url, { method: "POST", credentials: "include", // ★必須 headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }); | Windows認証やCookieベース認証では、credentials がないと実質401→CORSエラーに見える。 |
| 5 | IIS Express依存を切り分けdotnet run でKestrelを直接起動し、POSTが通るか確認。通るならIIS Express固有の問題。開発中はKestrelを使うか、IIS側の認証/プリフライト許可を調整。 | 実際にKestrelでは解決したという報告が多い。 |
| 6 | 最小構成サンプル(本記事後半に全文あり) | GET/POSTともに正常動作する雛形を提示。 |
なぜ「GETは成功、POSTだけCORSエラー」になるのか
キモはプリフライトです。クロスオリジンで POST(しかも Content-Type: application/json などの非シンプルヘッダー)を送ると、ブラウザは最初に OPTIONS を投げて「このメソッドとヘッダー、資格情報を送っても良いか?」をサーバーに問い合わせます。ここでサーバーが正しく応答できないと、本番の POST は送信すらされません。
Windows認証が有効なIIS Expressでは、OPTIONS 自体に認証を要求してしまい401応答を返すケースがよくあります。401応答には通常CORSヘッダーが付かないため、ブラウザ視点では「Access-Control-Allow-Origin が無い=CORS違反」と判断され、上記のエラーが表示されます。GETは「シンプルリクエスト」でプリフライト不要のため通っている、という理屈です。
HTTPで見る理想のプリフライト応答
OPTIONS /todoitems HTTP/1.1
Origin: https://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: [https://localhost:5173](https://localhost:5173)
Vary: Origin
Access-Control-Allow-Methods: GET,POST,OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
ポイントは Allow-Origin が実際のオリジン値になっていること(ワイルドカード不可)と、Allow-Credentials: true が返っていることです。前者は WithOrigins(...).AllowCredentials() の組み合わせで解決します。
正しいCORS設定とミドルウェア順序
Program.cs(.NET 8/9 Minimal API)の基本形
using Microsoft.AspNetCore.Authentication.Negotiate;
const string CORS = "FrontEnd";
var builder = WebApplication.CreateBuilder(args);
// 認証・認可
builder.Services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
.AddNegotiate();
builder.Services.AddAuthorization();
// CORS
builder.Services.AddCors(o => o.AddPolicy(CORS, p =>
p.WithOrigins(
"[https://frontend.example.com](https://frontend.example.com)",
"[https://localhost:5173](https://localhost:5173)", // Vite等
"[https://localhost:3000](https://localhost:3000)") // React等
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()
));
// アプリ
var app = builder.Build();
app.UseHttpsRedirection();
// ★順序が最重要:CORS → 認証 → 認可
app.UseCors(CORS);
app.UseAuthentication();
app.UseAuthorization();
// サンプルAPI
app.MapGet("/ping", () => Results.Ok(new { ok = true }))
.RequireCors(CORS);
app.MapPost("/todoitems", (TodoItem item) => Results.Created($"/todoitems/{item.Id}", item))
.RequireAuthorization() // Windows認証が必要なら付与
.RequireCors(CORS);
app.Run();
record TodoItem(int Id, string Title);
よくある誤りは、UseAuthentication()/UseAuthorization() を UseCors() より前に置いてしまうことです。これだけでプリフライトが401になり、POSTが一切届かなくなります。
エンドポイント単位でCORSを指定する(必要な場合)
APIが大規模になり、エンドポイントごとに許可するオリジンを変えたい場合はグループ化が便利です。
var v1 = app.MapGroup("/api/v1").RequireCors("FrontEnd");
v1.MapPost("/orders", ...).RequireAuthorization();
フロントエンド側の必須設定
fetchの例
await fetch("https://api.local.example/todoitems", {
method: "POST",
credentials: "include", // ★重要
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Write article" })
});
Axiosの例
axios.post("https://api.local.example/todoitems",
{ title: "Write article" },
{
withCredentials: true, // ★重要
headers: { "Content-Type": "application/json" }
});
- Authorizationヘッダーを送る場合、プリフライトの
Access-Control-Request-Headersにauthorizationが含まれます。サーバー側は.AllowAnyHeader()(または.WithHeaders("Authorization", ...))で許可しておきます。 - Cookieベース認証を併用するなら、クロスサイトで使うCookieは
SameSite=None; Secureが必要です(Windows認証のみなら通常不要)。 - フロントとAPIのスキーム(http/https)は揃えるか、双方をhttpsに。混在はブラウザのセキュリティで弾かれがちです。
IIS Express固有の落とし穴と対処
匿名認証(Anonymous)を有効にする
Windows認証だけを有効にし、匿名認証を無効にしていると、IIS Expressが OPTIONS も含め全てのリクエストに認証を要求し、プリフライトが401で失敗しがちです。開発中は次のいずれかで回避します。
- Visual Studioのプロジェクト設定(プロパティ → デバッグ → プロファイル「IIS Express」)で
Windows Authentication: 有効、Anonymous Authentication: 有効にする。 applicationhost.configで該当サイトの認証を調整する。
applicationhost.configの一例
Visual Studioのソリューション隣にある .vs\config\applicationhost.config で、アプリのスコープに以下のような設定が入っているか確認します(例:一部抜粋)。
<system.webServer>
<security>
<authentication>
<anonymousAuthentication enabled="true" />
<windowsAuthentication enabled="true" />
</authentication>
</security>
<!-- ★IIS CORS Module導入済み環境のみ(任意)。ASP.NET CoreのCORSがあれば必須ではない -->
<cors enabled="true">
<add origin="https://localhost:5173">
<allowHeaders allowAllRequestedHeaders="true" />
<allowMethods>
<add method="GET" />
<add method="POST" />
<add method="OPTIONS" />
</allowMethods>
<allowCredentials>true</allowCredentials>
</add>
</cors>
</system.webServer>
※ <cors> 要素はIIS CORS Moduleが入っていない環境では使えません。導入していなくても、ASP.NET Core側のCORSだけで十分対応可能です。
Kestrelでの切り分け
dotnet run(Kestrel直起動)に切り替えてPOSTが通るなら、原因はIIS Express側にあります。開発中はKestrelをデフォルトにし、IIS/IIS Expressでの動作確認は最終段のみに絞ると生産的です。
最小構成サンプル:これでGET/POSTともに動く
Program.cs(フル)
using Microsoft.AspNetCore.Authentication.Negotiate;
const string CORS = "FrontEnd";
var builder = WebApplication.CreateBuilder(args);
// 認証・認可
builder.Services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
.AddNegotiate();
builder.Services.AddAuthorization();
// CORSポリシー
builder.Services.AddCors(o => o.AddPolicy(CORS, p =>
p.WithOrigins("[https://localhost:5173](https://localhost:5173)", "[https://frontend.example.com](https://frontend.example.com)")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()
));
var app = builder.Build();
app.UseHttpsRedirection();
// ★重要:順序
app.UseCors(CORS);
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/ping", () => Results.Ok(new { ok = true }))
.RequireCors(CORS);
app.MapPost("/todoitems", (TodoItem item) => Results.Created($"/todoitems/{item.Id}", item))
.RequireAuthorization()
.RequireCors(CORS);
app.Run();
public record TodoItem(int Id, string Title);
launchSettings.json(IIS Expressプロファイルの例)
{
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"windowsAuthentication": true,
"anonymousAuthentication": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"MinimalApi": {
"commandName": "Project",
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7194;http://localhost:5194"
}
}
}
フロントエンド(開発中Vite/React等)
const res = await fetch("https://localhost:7194/todoitems", {
method: "POST",
credentials: "include", // これが無いとWindows認証はほぼ失敗
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: 1, title: "Task" })
});
console.log(await res.json());
デバッグ手順:3分で原因特定
- DevToolsのNetworkタブを開き、失敗したPOSTの直前にある
OPTIONSを選択。 - Request Headersに
Origin、Access-Control-Request-Method、Access-Control-Request-Headersが付いているか確認。 - Response Headersに
Access-Control-Allow-OriginとAccess-Control-Allow-Credentialsが付いているか確認。無ければCORSヘッダー未付与=ミドルウェア順序かIIS側認証が原因。 - ステータスコードが 401/403/404/405 のどれかなら、CORSの前に落ちています。
UseCors()の順序、IISの匿名認証、WebDAV/ハンドラの干渉を疑う。 - フロントのリクエストオプションに
credentials: "include"(またはwithCredentials: true)があるか確認。
よくある落とし穴と対処
- AllowAnyOrigin + AllowCredentials を併用
セキュリティ仕様で禁止。必ずWithOrigins(...)を使う。 - Originのポート/スキーム違い
https://localhost:5173とhttp://localhost:5173は別オリジン。両方必要なら両方列挙。 - Authorizationヘッダーを許可していない
プリフライトがauthorizationを要求し、サーバーが許可していないと失敗。.AllowAnyHeader()で回避。 - OPTIONSを独自に処理
手動レスポンスはCORSヘッダーの付け忘れが起こりやすい。基本はフレームワークに任せる。 - CookieのSameSite設定
OpenID Connect等のCookie認証をクロスサイトで使うならSameSite=None; Secureを付ける。付いていないとブラウザがCookieを送らず401に。 - WebDAVやハンドラがOPTIONSを奪う
フルIISではWebDAVモジュールが405を返すことあり。不要なら無効化。IIS Expressでは通常入っていないが、ハンドラの競合には注意。
原因別・解決クックブック
| 現象 | 原因の典型 | 対処 |
|---|---|---|
| OPTIONSが401 | UseCorsの順序が遅い/IISが匿名を拒否 | UseCorsを認証より前に。IIS Expressは匿名認証を有効に。 |
| OPTIONSが405 | WebDAV/ハンドラがOPTIONSを禁止 | モジュールを無効化、またはIIS CORS Module/ASP.NET Core CORSで許可。 |
| OPTIONSが200だが本番POSTが401 | 資格情報が送られていない | フロントで credentials: "include" / withCredentials: true。 |
Allow-Originが* | AllowAnyOriginとAllowCredentialsの誤用 | WithOrigins(...).AllowCredentials() に変更。 |
| 開発では成功、本番IISで失敗 | 本番IISの認証/モジュール設定が異なる | IIS側にもCORS/認証の設計を反映。匿名認証やCORSモジュールの可否を確認。 |
運用ベストプラクティス
- 本番はオリジンを固定(ワイルドカード禁止)。
- HTTPSを強制し、HSTS/リダイレクトを徹底。
- 面倒なときはリバースプロキシ(Nginx/Traefik/Azure Front Door等)側でCORS終端。アプリの責務を減らせます。
- APIのバージョン毎に
MapGroup+RequireCorsで明示的に制御。 - 監視にCORSヘッダーの有無を含め、デプロイ差分で早期検知。
再発防止のための最終チェックリスト
- Program.cs:UseCors → UseAuthentication → UseAuthorization の順序になっている。
- CORSポリシー:WithOrigins で実際のフロントURLを完全一致で列挙(スキーム・ポート含む)。
- AllowCredentials() を付けつつ、AllowAnyOrigin() は使っていない。
- AllowAnyHeader()/AllowAnyMethod() を設定済み(AuthorizationやContent-Typeが通る)。
- フロント:credentials: “include” / withCredentials: true を付与。
- IIS Express:匿名認証 ON、Windows認証 ON。
OPTIONSが401/405になっていない。 - DevToolsでプリフライトに
Access-Control-Allow-*が返っている。 - (Cookie利用時)SameSite=None; Secure を設定。
まとめ
「GETは通るのにPOSTだけCORSエラー」は、ほぼ確実にプリフライトが認証やミドルウェアの順序で阻害されているサインです。ASP.NET Core側では WithOrigins + AllowCredentials、UseCors → UseAuthentication → UseAuthorization の順序を守り、フロントでは credentials: "include" を付ける。IIS Expressを使う場合は匿名認証を有効化して OPTIONS を素通しさせる――この3点を押さえれば、開発環境でも本番でも安定して動作します。詰まったらまずプリフライトの応答ヘッダーを確認し、本文のチェックリストに沿って一つずつ潰していってください。
付録:トラブル再現と解消のための比較コード
(NG例)CORSの順序が認証より後ろ
// ❌ これだとOPTIONSが401になりやすい
app.UseAuthentication();
app.UseAuthorization();
app.UseCors("FrontEnd");
(NG例)AllowAnyOriginとAllowCredentialsの併用
// ❌ 実行時例外。セキュリティ仕様で禁止
builder.Services.AddCors(o => o.AddPolicy("FrontEnd", p =>
p.AllowAnyOrigin().AllowCredentials()
));
(OK例)エンドポイント単位のCORS+認可
app.MapGroup("/api").RequireCors("FrontEnd")
.MapPost("/secure", () => Results.Ok())
.RequireAuthorization();
(参考)Networkタブでの見え方
OPTIONS のレスポンスヘッダーに以下が並んでいれば成功のサインです。
Access-Control-Allow-Origin: https://localhost:5173
Access-Control-Allow-Methods: GET,POST,OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Vary: Origin
付録:IIS/IIS Expressのよくある設定差分
| 項目 | 開発(IIS Express) | 本番(IIS) | 注意点 |
|---|---|---|---|
| 匿名認証 | ONにする | プリフライトのみ許可か、CORSモジュールで処理 | プリフライトは認証なしで通すのが基本。 |
| Windows認証 | ON | ON(サイト/アプリケーションレベル) | Negotiate/NTLMの優先度に応じてヘッダーが変わる。 |
| WebDAV | 通常なし | 環境により有効 | 有効だとOPTIONSが405になることがある。 |
| CORS処理 | ASP.NET Coreミドルウェア | ミドルウェア or IIS CORS Module | 二重設定は避ける。どちらか一方に統一。 |
付録:一発判定スクリプト(開発用)
ターミナルからプリフライトが通るかだけを即判定したいときのワンライナーです(PowerShell)。
$origin = "https://localhost:5173"
$api = "https://localhost:7194/todoitems"
$headers = @{
"Origin" = $origin
"Access-Control-Request-Method" = "POST"
"Access-Control-Request-Headers" = "content-type, authorization"
}
try {
$r = Invoke-WebRequest -Method Options -Uri $api -Headers $headers -SkipCertificateCheck
($r.Headers["Access-Control-Allow-Origin"] -eq $origin) -and
($r.Headers["Access-Control-Allow-Credentials"] -eq "true")
} catch {
"NG: $($_.Exception.Message)"
}
最後に
「POSTだけCORSでブロック」は、(1)オリジンの固定、(2)ミドルウェア順序、(3)資格情報の送信という3条件を満たせば解けます。IIS Express×Windows認証は特にプリフライトが詰まりやすいので、匿名認証の扱いとKestrelでの切り分けを覚えておくと、次からは数分で解消できます。

コメント