ASP.NET Core Web APIでPOSTのまま別URLへリダイレクトする方法|307/308・CORS・PRG・プロキシ実装まで完全解説

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+再取得に設計転換

実運用での推奨レシピ(支払い連携を例に)

  1. API が注文を受け付け、DB に保存(状態:Pending)。
  2. 署名付きフィールドとリダイレクト先 URL を API 応答として返す(C)。
  3. フロントはオートサブミットフォームでゲートウェイへ POST(E)。
  4. 決済後、ゲートウェイは自サイトの /callback にサーバー間で通知(D)。
  5. 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 など)はあるか?
  • タイムアウト・再試行・サーキットブレーカーの設定は?

この記事を書いた人

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

コメント

コメントする

目次