ASP.NET Core MVCで全コントローラを認証必須にしたときのリダイレクトループ(ReturnUrl肥大化)の原因と対策

ASP.NET Core MVC アプリで「すべてのコントローラを認証必須にしたい」と考えたとき、安易に MapControllers().RequireAuthorization() を使うと、ログインページへのアクセスですらリダイレクトループを起こし、ReturnUrl が何十回もネストされた異常に長い URL が生成されることがあります。この記事では、その正体と根本原因を分解しながら、グローバル認可フィルターと [AllowAnonymous] を使った、実践的かつ安全な解決パターンを詳しく解説します。

日程Fit。無料・登録不要。「いつ空いてる?」を、ひとつのリンクで。リンクを送って、○△×でかんたん日程調整。無料で日程を作る。
目次

ASP.NET Core MVC で「全コントローラ認証必須」を実現したい背景

業務システムや管理画面系の Web アプリでは、「ほぼすべての画面がログイン必須」であるケースが少なくありません。その場合、各コントローラやアクションメソッドに個別に [Authorize] 属性を付けていくと、次のような問題が起こりがちです。

  • 付け忘れにより「本来非公開の画面」が誰でも見られてしまう
  • コントローラやアクションが増えるたびに [Authorize] を付与する作業が発生する
  • チーム開発では「どこまでが認証必須なのか」の方針がぶれやすい

そのため、多くの開発者は「既定ではすべて認証必須」「一部だけ匿名アクセスを許可」というスタイルを取りたくなります。ASP.NET Core MVC では、手段として次の 2 つがよく検討されます。

  • app.MapControllers().RequireAuthorization(); を使って、ルートレベルで一括制御する
  • グローバル認可フィルター(AuthorizeFilter)を登録し、既定ポリシーで認証必須にする

一見どちらでも良さそうに見えますが、ログインページの扱いを誤ると、前者のアプローチでは致命的なリダイレクトループに陥ります。

MapControllers().RequireAuthorization() を使ったときに起こる問題

問題の構成を、まずはシンプルなコードで確認します。次のように Program.cs を書いたとします。


var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
                .AddCookie(options =>
                {
                    options.LoginPath = "/Home/Login";
                });

builder.Services.AddControllersWithViews();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

// すべてのルートに認証を要求
app.MapControllers().RequireAuthorization();

app.Run();

この構成で、未認証ユーザーが /Home/Index にアクセスすると、次の流れになります。

  1. /Home/Index へのリクエストが RequireAuthorization による認可チェックに引っかかる
  2. 未認証なので Cookie 認証ミドルウェアが「チャレンジ(Challenge)」を返却
  3. LoginPath = "/Home/Login" にリダイレクトし、ReturnUrl をクエリ文字列に付与

ここまでは期待どおりです。問題は次のステップです。

  1. リダイレクトされた先の /Home/Login?ReturnUrl=%2FHome%2FIndex も、同じく RequireAuthorization の対象になっている
  2. ログイン画面にもかかわらず、未認証扱い → Challenge → LoginPath へリダイレクト
  3. すでに ReturnUrl が付いている URL に、さらに ReturnUrl が重ねて付与される

結果として次のような URL がどんどん生成されていきます。


/Home/Login?ReturnUrl=/Home/Index

/Home/Login?ReturnUrl=/Home/Login?ReturnUrl=/Home/Index

/Home/Login?ReturnUrl=/Home/Login?ReturnUrl=/Home/Login?ReturnUrl=/Home/Index
...

これが何十回も繰り返されると、URL 長の制限を超えたり、Web サーバーやブラウザ側の制約に引っかかってエラーが発生します。ログでは 302 リダイレクトが異常に続いているように見えるはずです。

リダイレクトループのイメージ

ステップ要求 URL認可判定動作
1/Home/Index未認証ログインページへリダイレクト
2/Home/Login?ReturnUrl=/Home/Index未認証再びログインページへ(ReturnUrl 付き)
3/Home/Login?ReturnUrl=/Home/Login?ReturnUrl=/Home/Index未認証またログインページへ…
URL が肥大化し続ける

つまり、ログインページまで認証対象にしてしまったことが、リダイレクトループと長大な ReturnUrl の直接の原因です。

根本原因を分解して理解する

なぜこうなるのかを ASP.NET Core の認証・認可パイプラインの視点から整理しておくと、別の構成でも同じような問題を防ぎやすくなります。

認証(Authentication)と認可(Authorization)の役割

  • 認証:誰なのかを確認する(Cookie・JWT・外部 IdP など)
  • 認可:そのユーザーにアクセス権があるかどうかを判定する

ASP.NET Core MVC では、認可は主に [Authorize] 属性や AuthorizeFilter を通じて実行されます。認可フィルターが「認証済みか?」を確認し、未認証であれば「チャレンジ」を発生させます。

Cookie 認証の Challenge の動き

Cookie 認証では、Challenge が発生したとき、次のようなリダイレクト処理が行われます。

  • options.LoginPath に設定された URL(例: /Home/Login)に 302 でリダイレクト
  • 元々アクセスしようとしていた URL を ReturnUrl クエリとして付加

この「元の URL を ReturnUrl に積み上げる」仕組み自体は非常に便利ですが、ログインページにも認可フィルターが掛かっていると、自分自身への Challenge → リダイレクトを延々と繰り返すことになります。

安全な解決策:グローバル認可フィルター + [AllowAnonymous]

では、どうすれば「全コントローラ既定で認証必須」を実現しつつ、このリダイレクトループを確実に防げるのでしょうか。結論として、次の構成がシンプルかつ安全です。

  • グローバル認可フィルターで「既定は認証必須」にする
  • ログイン・ユーザー登録・パスワードリセットなどの公開ページには [AllowAnonymous] を付ける
  • Cookie 認証の LoginPath を、[AllowAnonymous] なアクションに向ける

実際の Program.cs の例を見てみましょう。


using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.Authorization;

var builder = WebApplication.CreateBuilder(args);

// 認証の設定(Cookie)
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
                .AddCookie(o =>
                {
                    o.LoginPath = "/Home/Login";        // ログインページ
                    o.AccessDeniedPath = "/Home/Denied"; // 権限不足時の遷移先(任意)
                });

// MVC + グローバル認可フィルター
builder.Services.AddControllersWithViews(o =>
{
    var policy = new AuthorizationPolicyBuilder()
                    .RequireAuthenticatedUser()
                    .Build();

    // ここで全体に「認証必須」を適用
    o.Filters.Add(new AuthorizeFilter(policy));
});

var app = builder.Build();

app.UseStaticFiles();
app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}"
);

// MapControllers().RequireAuthorization() は不要
app.Run();

そして、ログインアクションには必ず [AllowAnonymous] を付与します。


public class HomeController : Controller
{
    [AllowAnonymous]
    public IActionResult Login(string returnUrl = "/")
    {
        // 未ログイン時のログインフォーム表示
        // 認証成功後は returnUrl へリダイレクトする実装にする
        return View();
    }

    [HttpPost]
    [AllowAnonymous]
    public async Task<IActionResult> Login(LoginViewModel model, string returnUrl = "/")
    {
        if (!ModelState.IsValid)
        {
            return View(model);
        }

        // ユーザーの検証処理
        // ...

        // 認証成功時に Cookie を発行
        await HttpContext.SignInAsync(
            CookieAuthenticationDefaults.AuthenticationScheme,
            principal);

        return LocalRedirect(returnUrl);
    }

    public IActionResult Index()
    {
        // ここはグローバル認可フィルターにより認証必須
        return View();
    }
}

[AllowAnonymous] が付与されたアクションは、グローバル認可フィルターの対象から除外されます。その結果、ログインページには認可フィルターが掛からず、Challenge → 自分自身へのリダイレクトという無限ループが発生しなくなります

グローバル認可フィルター構成のメリット

メリット内容
付け忘れ防止すべてのコントローラ/アクションが既定で認証必須になるため、[Authorize] の付け忘れによる情報漏えいリスクを大幅に減らせます。
構造が明快「公開して良いアクションだけ [AllowAnonymous]」というルールに整理され、レビューがしやすくなります。
保守性向上認可ポリシーの変更が Program.cs だけで完結するため、方針変更時の修正範囲が小さくなります。
リダイレクトループ防止ログインページを認可対象から外すことで、ReturnUrl が増殖するループの根本原因を解消できます。

なぜ MapControllers().RequireAuthorization() を外すだけではダメなのか

「じゃあ RequireAuthorization() を削除して、必要なところだけ [Authorize] を付け直せばいいのでは?」と考えることもできます。確かに小規模なアプリならそれでも問題ありませんが、次のようなデメリットがあります。

  • コントローラやアクションが増えるたびに、都度 [Authorize] を付ける必要がある
  • 複数人で開発していると、「付ける/付けない」の判断が人によってバラバラになりやすい
  • プロジェクトが大きくなるほど「どの画面が公開で、どこから認証必須なのか」が分かりにくくなる

その点、グローバル認可フィルター方式であれば、「公開ページにだけ [AllowAnonymous]」というシンプルなルールで一貫性を保てるため、長期運用での安心感が大きくなります。

もう一つの選択肢:FallbackPolicy を使う方法

ASP.NET Core では、グローバル認可フィルターの代わりに、認可オプションの FallbackPolicy を利用して「既定は認証必須」を表現することも可能です。


builder.Services.AddAuthorization(options =>
{
    var policy = new AuthorizationPolicyBuilder()
                    .RequireAuthenticatedUser()
                    .Build();

    // 明示的な [AllowAnonymous] が付いていない限り、このポリシーが適用される
    options.FallbackPolicy = policy;
});

この場合も、[AllowAnonymous] は有効に機能します。AuthorizeFilter をフィルターとして追加するか、FallbackPolicy を使うかは好みの問題ですが、「全体は認証必須」「一部だけ匿名」という設計思想は同じです。

ログイン以外の「匿名許可ページ」の扱い

ログインページ以外にも、Biz ロジック上「未ログインでもアクセスさせたい」ページは存在します。典型的には次のようなものです。

  • ユーザー登録(SignUp)画面
  • パスワードリセット要求画面
  • メールアドレス確認(メール内リンク)
  • 利用規約・プライバシーポリシーなどの公開ページ

こうしたアクションにも忘れずに [AllowAnonymous] を付ける必要があります。


public class AccountController : Controller
{
    [AllowAnonymous]
    public IActionResult Register()
    {
        return View();
    }

    [HttpPost]
    [AllowAnonymous]
    public IActionResult Register(RegisterViewModel model)
    {
        // ユーザー登録処理
        // ...
        return RedirectToAction("Login", "Home");
    }

    [AllowAnonymous]
    public IActionResult ForgotPassword()
    {
        return View();
    }
}

運用開始前に、「匿名許可アクション一覧」を一度洗い出してリスト化しておくと、テストとレビューが非常にやりやすくなります。

よくある間違いパターンと症状

ASP.NET Core MVC の認証回りでは、似たような構成ミスが繰り返し発生します。代表的なものを症状ごとに整理しておきます。

パターン原因よくある症状
ログインページにも認可が掛かっているMapControllers().RequireAuthorization() + ログインアクションに [AllowAnonymous] が付いていないReturnUrl がネストし続けるリダイレクトループ
匿名ページが 401/403 になる公開ページに [AllowAnonymous] の付け忘れログアウト後にパスワードリセット画面へアクセスできない
権限不足と未ログインの区別が曖昧AccessDeniedPath を設定していない、または 403 ページを用意していないロール不足でもログイン画面に戻され、原因が分かりにくい
特定の API だけ JWT にしたいCookie 認証と JWT 認証を混在させたものの、スキームの指定が正しくない意図しないスキームで Challenge が走り、想定外のリダイレクトが発生する

Razor Pages / Areas / API での応用

ここまで ASP.NET Core MVC(コントローラ+ビュー)を前提に説明してきましたが、同じ考え方は Razor Pages や Areas、API プロジェクトにも応用できます。

Razor Pages の場合

Razor Pages では、ページ単位で [AllowAnonymous] を指定するために、ページの先頭に @attribute を記述します。


@page
@model LoginModel
@attribute [AllowAnonymous]

<h1>ログイン</h1>
...

サービス登録側では、MVC と同様にグローバル認可を設定します。


builder.Services.AddRazorPages(options =>
{
    var policy = new AuthorizationPolicyBuilder()
                    .RequireAuthenticatedUser()
                    .Build();

    options.Conventions.AuthorizeFolder("/"); // 全体を保護
    options.Conventions.AllowAnonymousToPage("/Login"); // ログインだけ匿名許可

    // もしくは AuthorizeFilter を使ったグローバル認可
});

API の場合(JWT 認証など)

API プロジェクトで JWT 認証を使う場合も、基本的な考え方は同じです。

  • 既定では全エンドポイントを認証必須(Bearer トークン必須)
  • 一部の「公開 API」のみ [AllowAnonymous] を付与

ただし API では、ブラウザリダイレクトではなく 401/403 のステータスコードを返すケースが多いため、「ReturnUrl が肥大化する」という問題は起きにくいものの、「ログインAPI自体が認証必須になってしまっている」という構成ミスはやはり発生しがちです。

AccessDeniedPath の設定で UX を上げる

Cookie 認証には AccessDeniedPath という設定があり、認証済みだが権限が足りない(403 Forbidden)場合の遷移先を指定できます。


builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
                .AddCookie(o =>
                {
                    o.LoginPath = "/Home/Login";
                    o.AccessDeniedPath = "/Home/Denied"; // ロール不足など
                });

さらに、権限不足用の画面には「どのロールが必要なのか」などをメッセージとして表示しておくと、ユーザーにも管理者にも優しい設計になります。


public class HomeController : Controller
{
    [AllowAnonymous]
    public IActionResult Denied()
    {
        return View();
    }

    [Authorize(Roles = "Admin")]
    public IActionResult AdminOnly()
    {
        return View();
    }
}

これにより、「ログインしているのにまたログイン画面に戻された…」という混乱を防ぎ、「ログインしてはいるが権限が足りない」という状況を明示できます。

運用前に確認しておきたいチェックリスト

最後に、ASP.NET Core MVC アプリを本番に出す前に確認しておきたいポイントをチェックリスト形式でまとめます。特に MapControllers().RequireAuthorization() からグローバル認可フィルターに移行した直後は、次のような観点でテストしておくと安心です。

  • Program.cs でグローバル認可フィルターまたは FallbackPolicy を設定しているか
  • ログインアクションに [AllowAnonymous] が付いているか
  • ユーザー登録/パスワードリセット/メール確認など、未ログインでもアクセスさせたいアクションに [AllowAnonymous] が付いているか
  • LoginPath[AllowAnonymous] なアクションを指しているか
  • AccessDeniedPath を設定し、403 ページが適切に表示されるか
  • ログイン前後で ReturnUrl を使った遷移が期待どおりに動作するか
  • 権限不足(ロール不足)の際に、想定どおりのメッセージが表示されるか
  • ブラウザの開発者ツールやサーバーログで、不要なリダイレクトが連続していないか

まとめ:ログインページの認可設定がすべてのカギ

ASP.NET Core MVC で「全コントローラを認証必須」にしたい場合、MapControllers().RequireAuthorization() を安易に使うと、ログインページまで認証対象となり、未認証ユーザーのアクセスがロジック上「未認証 → Challenge → LoginPath → 未認証…」と無限に繰り返されてしまいます。その際、毎回 ReturnUrl が付け足されていくため、URL が異常に長くなり、最終的にエラーにつながります。

この問題の本質的な解決策は次の 3 点に集約されます。

  • グローバル認可フィルターまたは FallbackPolicy で「既定は認証必須」にする
  • ログインページや公開ページには必ず [AllowAnonymous] を付与し、認可チェックをバイパスさせる
  • LoginPath[AllowAnonymous] なアクションに向ける

このパターンを押さえておけば、ASP.NET Core 9 Core MVC のような新しいバージョンでも安全に「全体保護+一部公開」の構成を適用できます。認証・認可は一度構成すると長く使い続ける部分なので、最初の設計段階で今回のポイントをしっかり押さえておくと、後々のトラブルやデバッグコストを大きく減らすことができます。

この記事を書いた人

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

コメント

コメントする

目次