ASP.NET Core Web API の [HttpPost] から別サイトへ遷移し、受け取ったフォームデータをそのまま相手へ渡したい——多くの開発者が一度はぶつかる壁です。HTTP の仕様上、単純なリダイレクトでは POST ボディが消えてしまいます。本稿は「なぜそうなるのか」を分解しつつ、実運用で安全に“POST のまま”を実現・代替する設計とコードを、局面別に徹底解説します。
問題の本質:リダイレクトは基本的に GET に落ちる
HTTP/1.0 以来、301/302 リダイレクトに遭遇したクライアントは、原則として GET で再リクエストします。したがって、RedirectPermanent()(301)や Redirect()(302)で外部サイトへ飛ばしても、POST ボディは送られません。この挙動はブラウザだけでなく多くの HTTP クライアント(ライブラリ)でも同様です。
HTTP ステータスと再リクエストの挙動
| ステータス | 意味 | メソッド | ボディ | 備考 |
|---|---|---|---|---|
| 301 Moved Permanently | 恒久的移動 | 多くのクライアントで GET に変更 | 破棄 | SEO 用途で有名。POST のままにはならない |
| 302 Found | 一時的移動 | 多くのクライアントで GET に変更 | 破棄 | 実質 301 と同様の挙動になることが多い |
| 303 See Other | 別の場所を見よ | GET に変更 | 破棄 | PRG(Post-Redirect-Get)でよく使う |
| 307 Temporary Redirect | 一時的移動(メソッド保持) | 元のメソッドを保持(= POST→POST) | 保持 | ブラウザ・クライアントが対応していれば POST 維持 |
| 308 Permanent Redirect | 恒久的移動(メソッド保持) | 元のメソッドを保持 | 保持 | 恒久的。誤用すると再投稿のリスク |
ASP.NET Core の対応メソッド(Controller ベース)
| メソッド | 返すステータス | メソッド保持 | 用途の目安 |
|---|---|---|---|
Redirect() | 302 | しない | 単純な GET 遷移 |
RedirectPermanent() | 301 | しない | 恒久的 GET 遷移 |
RedirectPreserveMethod() | 307 | する | POST を保持して一時的に別 URL へ |
RedirectPermanentPreserveMethod() | 308 | する | 恒久的にメソッドを保持(慎重に) |
RedirectToActionPreserveMethod() | 307 | する | アクション/同一アプリ向け(内部遷移) |
RedirectToPagePreserveMethod() | 307 | する | Razor Pages 向け(内部遷移) |
結論(要約)
- 標準の 301/302/303 は GET になるため、POST ボディは渡せません。
- 307/308(= Preserve Method)なら POST を保持できます。ただし ブラウザ/クライアント依存、CORS・Cookie・プリフライトの制約を必ず検証してください。
- Web API では「リダイレクトしない」設計(URL を返してクライアントが POST し直す、またはサーバーが代理で POST する)が現実的で堅牢です。
- 外部サイトへクエリで渡す場合は最小限・秘匿情報は載せない(URL はログや解析に残ります)。
実装パターンとサンプルコード
パターンA:PRG(Post–Redirect–Get)で設計を変える
「画面遷移」が目的なら PRG が王道です。POST を受けてサーバー側で処理→結果の識別子だけを付けて GET にリダイレクトします。フォームの再送信ダイアログや二重送信を防ぎやすいのが利点です。
// Controller ベース
[HttpPost]
public IActionResult Create(OrderInput model)
{
var id = _orderService.Create(model); // データ保存
// GET へ誘導(結果は id で再取得)
return RedirectToAction(nameof(Details), new { id });
}
[HttpGet]
public IActionResult Details(int id)
{
var vm = _orderService.Find(id);
return View(vm);
}
外部サイトに渡したいデータが大量・機密なら、自サイトで保存→トークンだけをクエリに乗せるのが安全です。外部サイトはそのトークンを使って API から取得します(相互認証が必要)。
パターンB:RedirectPreserveMethod で POST を保持(307/308)
仕様上 POST を保持できるのは 307/308 です。ASP.NET Core ではワンライナーで返せます。
// Controller ベース:307 Temporary Redirect
[HttpPost]
public IActionResult ForwardWithPost([FromBody] PaymentRequest body)
{
var url = "https://toolsdev.avtest.ink/test";
return RedirectPreserveMethod(url); // 307 + Location ヘッダ
}
// Minimal API(.NET 6 以降)
app.MapPost("/forward", (PaymentRequest body) =>
{
var url = "[https://toolsdev.avtest.ink/test](https://toolsdev.avtest.ink/test)";
return Results.Redirect(url, permanent: false, preserveMethod: true); // 307
});
注意:
- クライアントが 307/308 に対応している必要があります(現行ブラウザは概ね対応)。
- 遷移先が クロスオリジンの場合、CORS とプリフライトの影響を受けます。特に
application/jsonなどは OPTIONS プリフライトが発生し、遷移先が CORS を許可しないと失敗します。 - 遷移先が クッキー・セッションに依存するなら、SameSite=None; Secure 等の設定が必要です。近年のブラウザでは クロスサイト POST に Lax クッキーは送信されません。
- 308 は恒久なので、将来 URL を変えたくなったときに支障が出ます。まずは 307 から。
パターンC:クライアントに URL を返し、クライアントが POST し直す
Web API としてはこれが最もシンプルで壊れにくいです。API は「どこへ何を送って欲しいか」を返し、フロントエンドやモバイルアプリが改めて POST します。
// API 側
[HttpPost]
public IActionResult GetNextHop([FromBody] FormData data)
{
// 必要ならサーバー側で署名/暗号化して返す
return Ok(new {
redirectUrl = "https://toolsdev.avtest.ink/test",
method = "POST",
fields = new { id = data.Id, name = data.Name }
});
}
<!-- フロント(例:HTML): 受け取った fields をフォームで POST する -->
<form id="relay" method="post" action="https://toolsdev.avtest.ink/test">
<input type="hidden" name="id" value="">
<input type="hidden" name="name" value="">
</form>
<script>
// API の応答を前提にフォームを埋めて送信
fetch('/api/GetNextHop', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ id:10, name:'Bob' }) })
.then(r => r.json())
.then(({redirectUrl, fields}) => {
const f = document.getElementById('relay');
f.action = redirectUrl;
f.elements['id'].value = fields.id;
f.elements['name'].value = fields.name;
f.submit(); // 画面遷移を伴うトップレベル POST(CORS の読取制限は受けない)
});
</script>
トップレベルナビゲーションとしてのフォーム送信は、レスポンスの読取ができない代わりに XHR/Fetch と違って CORS の影響を受けません(ただしクッキーは SameSite の制約を受けます)。
パターンD:サーバーが代理で POST(プロキシ)する
「ユーザーを遷移させる必要はない。データだけ外部へ送りたい」なら、サーバーが裏側で HttpClient を使って POST し、結果だけを呼び出し元に返します。クッキーやヘッダーの引き継ぎ、タイムアウト、再試行などを細かく制御できます。
// Program.cs
builder.Services.AddHttpClient("relay")
.ConfigureHttpClient(c => {
c.Timeout = TimeSpan.FromSeconds(20);
});
[HttpPost]
public async Task ProxyAsync([FromServices] IHttpClientFactory factory, [FromBody] object body, CancellationToken ct)
{
var client = factory.CreateClient("relay");
using var req = new HttpRequestMessage(HttpMethod.Post, "[https://toolsdev.avtest.ink/test](https://toolsdev.avtest.ink/test)")
{
Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(body), System.Text.Encoding.UTF8, "application/json")
};
// 必要に応じてヘッダーを引き継ぐ
if (Request.Headers.TryGetValue("X-Correlation-ID", out var cid))
req.Headers.TryAddWithoutValidation("X-Correlation-ID", cid.ToString());
```
using var res = await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
// そのままパススルー(例)
var content = await res.Content.ReadAsStringAsync(ct);
return StatusCode((int)res.StatusCode, content);
```
}
大きなペイロードや機密データ、厳しい CORS 制約がある統合先では、この方式が最も安定します。
パターンE:HTML を返して自動 POST(「フォームオートサブミット」)
ブラウザ遷移が前提なら、API から HTML のフォームを返し、読み込み完了時に自動送信させることで「POST のまま外部へ遷移しているように見せる」ことができます。application/x-www-form-urlencoded ならプリフライトを避けやすいのも利点。
// 返却する HTML(ContentResult)
[HttpPost]
public IActionResult AutoPost([FromForm] PayInput model)
{
var html = $$"""
<!doctype html>
<meta charset="utf-8">
<title>Redirecting...</title>
<form id="f" method="post" action="https://toolsdev.avtest.ink/test">
<input type="hidden" name="id" value="{{model.Id}}" />
<input type="hidden" name="name" value="{{System.Net.WebUtility.HtmlEncode(model.Name)}}" />
</form>
<noscript>このページは自動的に送信されます。<button form="f">続行</button></noscript>
<script>document.getElementById('f').submit();</script>
""";
return new ContentResult { Content = html, ContentType = "text/html; charset=utf-8" };
}
この方式はユーザー体験が明快で、CORS に左右されにくい一方、API としての純粋性は下がります(返り値が HTML 固定)。用途を絞って使いましょう。
パターンF:同一アプリ内の移動はルート値またはクエリで
同一アプリ内での遷移は RedirectToAction()/RedirectToPage() が簡潔です。必要な情報を最小限のクエリあるいはルート値で持たせ、遷移先で再ロードします。
return RedirectToAction("Test", new { id = 10, name = "Bob" });
// or
return RedirectToPage("/Test", new { id = 10 });
クロスドメイン時の落とし穴(CORS, Cookie, プリフライト)
- CORS は「読み取り」保護であり、トップレベルのフォーム送信自体は許可されます。ただし XHR/Fetch で外部に POST する場合は、相手サーバーが
Access-Control-Allow-Originを返す必要があります。 - プリフライト:
application/json、カスタムヘッダー、PUT/DELETE などはOPTIONSが先行します。307/308 での再送先でもプリフライトが必要になり得ます。 - SameSite Cookie:クロスサイト POST でセッションを使うなら、
SameSite=None; Secureを付与。デフォルト Lax のままでは Cookie が送られません。
セキュリティ設計のポイント
- 秘匿情報を URL に載せない(ログ・解析・リファラに残る)。必要ならサーバー保存+トークン参照。
- HMAC/JWT などでフィールド改ざんを検出(署名つきパラメータ)。
- CSRF:オートサブミットやクロスサイト POST は第三者が悪用可能。受け側は CSRF トークンや Origin/Referer の検証を。
- Idempotency-Key:308 や再送で二重実行しないためのキーを導入。
- Open Redirect 対策:ユーザー入力の URL に無条件リダイレクトしない。ホワイトリストで許可ドメインを限定。
「POST のまま」リダイレクトの挙動を最小コードで確かめる
// Minimal API 検証用
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// 1) 307 を返すエンドポイント
app.MapPost("/go307", (HttpRequest req) => {
return Results.Redirect("[https://toolsdev.avtest.ink/test](https://toolsdev.avtest.ink/test)", permanent:false, preserveMethod:true);
});
// 2) 受け側のダミー(ローカルで別ポートに用意してもよい)
app.MapPost("/echo", async (HttpRequest req) => {
using var reader = new StreamReader(req.Body);
var body = await reader.ReadToEndAsync();
return Results.Text($"method={req.Method}, body={body}");
});
app.Run();
curl で試すと違いがよく分かります。
# 302: GET 化される(-L で追跡するとボディ消失)
curl -v -L -X POST -d "x=1" http://localhost:5000/go302
# 307: POST が保持される(追跡先に x=1 が届く)
curl -v -L -X POST -d "x=1" [http://localhost:5000/go307](http://localhost:5000/go307)
「どの方式を選ぶべきか」意思決定ガイド
| 状況 | 推奨パターン | 理由 | 注意点 |
|---|---|---|---|
| 画面遷移が目的(結果ページへ導きたい) | PRG(A) | 二重送信対策、UX が安定 | 外部サイトにはトークンで委譲 |
| どうしても「POST のまま外部へ」 | 307(B)またはオートサブミット(E) | メソッド保持またはフォーム送信で実現 | CORS/プリフライト/クッキーの挙動確認が必須 |
| API 連携で遷移は不要 | サーバープロキシ(D) | セキュリティと再送制御が容易 | タイムアウト・スループットを監視 |
| SPA/モバイルからの呼び出し | URL を返してクライアントが POST(C) | CORS 設計をクライアント側に集約 | エラーハンドリング・再試行をクライアント実装 |
ASP.NET Core の具体テクニック集
1. Preserve Method 系メソッドの使い分け
RedirectPreserveMethod(url)… 307 を返す。まずはこれ。RedirectPermanentPreserveMethod(url)… 308 を返す。恒久化の意図があるときのみ。RedirectToActionPreserveMethod()/RedirectToPagePreserveMethod()… 同一アプリ内の POST 維持。- Minimal API は
Results.Redirect(url, permanent, preserveMethod)で統一。
2. CORS 設定の基本形
// Program.cs(受け側のサーバー)
// ※ 外部サイト側のサーバーで設定が必要。自分側で設定しても他ドメインには効きません。
builder.Services.AddCors(o => o.AddPolicy("api", p =>
p.WithOrigins("https://your-origin.example")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials() // Cookie を使う場合
));
var app = builder.Build();
app.UseCors("api");
3. SameSite の落とし穴
// CookieOptions の例
Response.Cookies.Append("sid", sessionId, new CookieOptions{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.None, // クロスサイト POST でクッキーを運ぶなら None が必要
Expires = DateTimeOffset.UtcNow.AddMinutes(30)
});
4. 署名付きフィールド
クライアントが送るフィールドを受け側で検証できるよう、簡易 HMAC 署名を付けます。
public static string Sign(IDictionary<string,string> fields, string secret)
{
var raw = string.Join("&", fields.OrderBy(kv => kv.Key).Select(kv => $"{kv.Key}={kv.Value}"));
using var hmac = new System.Security.Cryptography.HMACSHA256(System.Text.Encoding.UTF8.GetBytes(secret));
var sig = Convert.ToHexString(hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(raw)));
return sig.ToLowerInvariant();
}
テストと観測可能性(Observability)
- 統合テストでは
WebApplicationFactory<T>を用い、307 を実際に追跡してボディが届くことを検証。 - 相関 ID(例:
X-Correlation-ID)をヘッダーで伝搬し、ログを結び付ける。 - タイムアウト・再試行:外部連携は常に失敗する可能性を見込む。指数バックオフとキャンセルを実装。
トラブルシューティング(実際に起こる問題と対処)
| 症状 | 原因 | 対策 |
|---|---|---|
| 307 を返したのに外部でボディが空 | クライアントが 307 に非対応/Fetch が method を変えている | ブラウザで再現し、ネットワークタブで確認。必要なら E(オートサブミット)や D(プロキシ)に切替 |
| 外部が 403/失敗する | CORS/プリフライト、CSRF チェック、SameSite でブロック | 受け側の CORS 設定・CSRF 設定・Cookie 属性を見直す |
| 二重実行が発生 | ユーザーのリロード/再送/ネットワーク再試行 | Idempotency-Key 導入、サーバー側で同一キーを黙殺 |
| 大容量ファイルで遅い/失敗 | 307/308 はボディを再送するため負荷増大 | サーバープロキシ(D)でストリーミング、または 303+再取得に設計転換 |
実運用での推奨レシピ(支払い連携を例に)
- API が注文を受け付け、DB に保存(状態:Pending)。
- 署名付きフィールドとリダイレクト先 URL を API 応答として返す(C)。
- フロントはオートサブミットフォームでゲートウェイへ POST(E)。
- 決済後、ゲートウェイは自サイトの
/callbackにサーバー間で通知(D)。 - UI は
/orders/{id}(GET)に PRG で誘導(A)。
この構成はユーザー体験を維持しつつ、CORS/CSRF/再送問題を最小化できます。
サンプル:最小構成のコード一式
// Program.cs(Minimal API)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("proxy");
var app = builder.Build();
// 1) PRG: 受領→結果ページへ
app.MapPost("/orders", (OrderInput input) => Results.Redirect($"/orders/{Guid.NewGuid()}", false, false));
// 2) 307: POST を保持して外部へ
app.MapPost("/forward", (PaymentRequest body) => Results.Redirect("[https://toolsdev.avtest.ink/test](https://toolsdev.avtest.ink/test)", false, true));
// 3) URL を返してクライアントが POST
app.MapPost("/next-hop", (PaymentRequest body) => Results.Json(new {
redirectUrl = "[https://toolsdev.avtest.ink/test](https://toolsdev.avtest.ink/test)",
method = "POST",
fields = new { body.Id, body.Name }
}));
// 4) サーバーが代理 POST
app.MapPost("/proxy", async (IHttpClientFactory f, PaymentRequest body) => {
var c = f.CreateClient("proxy");
var res = await c.PostAsJsonAsync("[https://toolsdev.avtest.ink/test](https://toolsdev.avtest.ink/test)", body);
return Results.StatusCode((int)res.StatusCode);
});
app.Run();
public record OrderInput(string Item, int Qty);
public record PaymentRequest(int Id, string Name);
まとめ
- 301/302/303 は GET になるため、POST ボディは送れません。
- 307/308(Preserve Method)なら POST を維持できますが、ブラウザ/クライアント・CORS・Cookieの制約を必ず検証。
- Web API の実務では、URL を返してクライアントが POST し直すか、サーバーが代理で POSTするのが堅実。
- 画面遷移目的なら PRG を採用。外部にはトークン委譲で安全に。
- やむを得ずクエリで渡す場合は最小限・機密は載せない、署名や有効期限付きトークンを併用。
付録:チェックリスト
- どのユーザー体験が必要?(遷移/非遷移)
- 外部の受け側は何を期待?(コンテントタイプ、認証、CSRF)
- クッキーは必要? SameSite 属性は?
- ペイロードは大きい? 再送リスクは?
- ログ・監査に残してよい情報だけを URL に載せているか?
- 二重実行防止の仕組み(Idempotency-Key など)はあるか?
- タイムアウト・再試行・サーキットブレーカーの設定は?

コメント