ASP.NET CoreでIdentityを使わずCookie認証を自前実装すると、ログインフォームの送信先やHTTPメソッドが噛み合わずHTTP 405(Method Not Allowed)に遭遇しがちです。この記事では、Razor PagesとMVCの違いを軸に、405の原因と最小ログインフロー、確認ポイントを具体例で整理します。
ASP.NET Coreの「Cookie認証」と「Identity」の関係を整理する
まず押さえておきたいのは、Cookie認証とASP.NET Core Identityは同じものではない、という点です。Cookie認証は「ログイン済みであることをブラウザのCookieで維持する仕組み」で、Identityは「ユーザー管理(登録・パスワードハッシュ・ロール・2FA・ロックアウト・外部ログインなど)まで一式を提供する枠組み」です。
Identityを使わない構成では、Cookieを発行するところ(SignInAsync)はASP.NET Core標準の仕組みを使いつつ、ユーザー名とパスワードを検証する部分や、どんなClaimをCookieに入れるかを自分で実装します。つまり、Cookie認証は「入口(ログイン)」「通行証(Cookie)」「門番([Authorize])」の仕組みで、Identityは「通行証を発行するための身分証発行所+台帳」まで用意するイメージです。
| 観点 | Identityを使う | IdentityなしでCookie認証を自前実装 |
|---|---|---|
| ユーザー管理 | ユーザー作成・更新・パスワード変更などが用意されている | 既存DB/外部IDP/社内認証などに合わせて自分で実装 |
| ログインUI | 既成のUI/テンプレート(またはScaffold)が使える | MVC/Razor Pages/Minimal APIなどアプリの構造に合わせて自作 |
| Cookie発行 | 内部的にCookie認証(またはトークン)を利用 | HttpContext.SignInAsyncで自分で発行 |
| ハマりどころ | 設定項目が多く、理解せずに入れるとブラックボックス化しやすい | ルーティング/HTTPメソッド/ReturnUrl/セキュリティを自分で揃える必要 |
今回のように「Identityパッケージは使わず、Cookie認証だけでログインを作りたい」というケースでは、アプリがRazor PagesなのかMVCなのかで実装の入口が変わります。ここを取り違えると、フォーム送信が正しいハンドラーに届かず、HTTP 405が出やすくなります。
HTTP 405(Method Not Allowed)を正しく理解する
HTTP 405は「URL(エンドポイント)は存在するが、そのHTTPメソッドは許可されていない」という意味です。例えば、同じ/Account/Loginでも、サーバー側がGETしか受け付けない設定なのに、ブラウザがPOSTした場合などに発生します。
405を見たら、まず「ルートが間違っている」のではなく、メソッドの不一致(GET/POSTなど)を疑うのが近道です。混同しやすいステータスと違いを表で整理します。
| ステータス | 意味 | ログイン周りでの典型例 |
|---|---|---|
| 400 Bad Request | リクエストが不正(形式/検証エラー) | CSRF対策のトークン不足、モデルバインドに必要な値が欠けている |
| 401 Unauthorized | 認証が必要、または認証情報が無効 | APIにトークンなしでアクセス、Cookieが無効 |
| 403 Forbidden | 認証はできたが権限不足 | ログイン済みだが管理者ロールが必要な画面へアクセス |
| 404 Not Found | URLに対応するエンドポイントがない | コントローラー/ページが存在しない、ルーティング設定ミス |
| 405 Method Not Allowed | URLはあるが、そのメソッドは未対応 | GETだけのLoginにPOSTした、またはPOSTだけのLoginにGETした |
実務的なコツとして、405レスポンスにはAllowヘッダーが付くことがあります。開発者ツールでレスポンスヘッダーを見てAllow: GETのように出ていれば、「そのURLはGETしか受けない」ことがほぼ確定です。
今回の相談のポイント:OnPostAsyncにPOSTしているのに405が返る理由
相談内容を要約すると、「Microsoftのサンプルで見かけたOnPostAsyncにログインフォームからPOSTしているつもりなのに、毎回405が返る。初回POSTは本当にそこに飛ぶのか?」という悩みです。
結論から言うと、OnPostAsyncはRazor PagesのPageModelに対する“POSTハンドラー”です。アプリがMVC(コントローラー+ビュー)構成なのに、Razor Pages前提のOnPostAsyncをそのまま持ち込むと、MVCのルーティング/アクション選択とは噛み合いません。その結果、たとえば次のような状態になりやすく、405につながります。
- フォームの送信先が
/Account/Loginになっているが、MVC側に[HttpPost] Loginが存在しない(GETだけ) - Razor Pagesのファイルは置いたつもりでも、アプリでRazor PagesをMapしていない(または場所が違う)
- フォームが意図せずGETで送信され、POSTハンドラーに届いていない(
method="post"がない、タグヘルパー指定ミスなど)
質問者が最終的に気づいたのは「サンプルはRazor Pages前提、自分のアプリはMVCだった」という前提差で、MVC用に書き換えて解決しています。ここから先は、Razor Pages版とMVC版を分けて、ログインの“正しい入口”を整理します。
Razor Pages版:OnPostAsyncが呼ばれるまでの流れ
Razor Pagesは、ページ(.cshtml)とPageModel(.cshtml.cs)がセットになり、同一URLに対してGET/POSTなどのHTTPメソッドで処理が分岐します。対応関係は基本的に以下です。
| HTTPメソッド | PageModel側のメソッド名 | 役割 |
|---|---|---|
| GET | OnGet / OnGetAsync | 画面表示、ReturnUrlの初期化など |
| POST | OnPost / OnPostAsync | フォーム送信の受け取り、認証、Cookie発行 |
このため、Razor PagesでOnPostAsyncを入口にするなら、最低限次の条件が揃っている必要があります。
- ログインページが
Pages/Account/Login.cshtmlのようにRazor Pagesとして配置されている builder.Services.AddRazorPages()とapp.MapRazorPages()が有効になっている- フォームがPOSTで、かつログインページに送信される(
<form method="post">)
Razor Pagesの最小Login PageModel例
以下は「Identityなし、Cookie認証だけでログインCookieを発行する」最小構成のイメージです。実運用では認証ロジック、パスワードハッシュ、失敗回数制限などを必ず強化してください。
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
public class LoginModel : PageModel
{
[BindProperty]
public string Username { get; set; }
[BindProperty]
public string Password { get; set; }
[BindProperty]
public string ReturnUrl { get; set; }
public void OnGet(string returnUrl = null)
{
ReturnUrl = string.IsNullOrEmpty(returnUrl) ? "/Account/Private" : returnUrl;
}
public async Task<IActionResult> OnPostAsync(string returnUrl = null)
{
// 送信時にreturnUrlが来ないケースもあるため、二重にフォールバック
ReturnUrl = string.IsNullOrEmpty(returnUrl) ? (ReturnUrl ?? "/") : returnUrl;
if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))
{
ModelState.AddModelError(string.Empty, "ユーザー名またはパスワードが不正です。");
return Page();
}
// TODO: 自前の認証ロジック(DB照合など)
if (!AuthenticateUser(Username, Password))
{
ModelState.AddModelError(string.Empty, "ユーザー名またはパスワードが不正です。");
return Page();
}
var claims = new List<Claim>
{
new Claim(ClaimTypes.Name, Username),
new Claim(ClaimTypes.Role, "User")
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
var authProperties = new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTimeOffset.UtcNow.AddHours(8)
};
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
authProperties);
return LocalRedirect(ReturnUrl);
}
private bool AuthenticateUser(string username, string password)
{
// 例: ここでDBのパスワードハッシュと照合する
return true;
}
}
Razor Pagesのフォーム例(重要:method=”post”)
Razor Pagesは「同じページにPOSTする」設計が自然です。フォーム側でmethodを指定しないと、ブラウザはGETで送ってしまうため、意図せずOnGetが呼ばれたり、POSTハンドラーがない場合に405になったりします。
<form method="post">
<div>
<label>ユーザー名</label>
<input asp-for="Username" />
</div>
<div>
<label>パスワード</label>
<input asp-for="Password" type="password" />
</div>
<input asp-for="ReturnUrl" type="hidden" />
<button type="submit">ログイン</button>
</form>
この構成で、未ログイン状態で保護ページ(例:/Account/Private)へアクセスすると、Cookie認証ミドルウェアがログインページへリダイレクトし、ReturnUrlがクエリに付与される、という流れが作れます。
MVC版:OnPostAsyncは使わず、[HttpPost]アクションを入口にする
MVC構成では、ログインの入口はPageModelのOnPostAsyncではなく、コントローラーのアクションです。HTTPメソッドは属性で明示するのが分かりやすく、基本は「GETでログイン画面表示」「POSTで認証してCookie発行」になります。
| 機能 | MVCでの実装場所 | 代表的な形 |
|---|---|---|
| ログイン画面表示 | コントローラー | [HttpGet] Login() |
| ログイン処理(POST) | コントローラー | [HttpPost] Login(...) |
| ログイン画面(HTML) | Views/Account/Login.cshtml | <form method="post">... |
MVCの最小AccountController例
以下はMVCでCookieを発行する最小例です。Razor Pagesと違い、POST用のアクションが存在しないと、フォームがPOSTした瞬間に405になりやすい点が重要です。
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
public class AccountController : Controller
{
[HttpGet]
public IActionResult Login(string returnUrl = null)
{
ViewData["ReturnUrl"] = returnUrl;
return View();
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(string username, string password, string returnUrl = null)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
ModelState.AddModelError(string.Empty, "ユーザー名またはパスワードが不正です。");
ViewData["ReturnUrl"] = returnUrl;
return View();
}
if (!AuthenticateUser(username, password))
{
ModelState.AddModelError(string.Empty, "ユーザー名またはパスワードが不正です。");
ViewData["ReturnUrl"] = returnUrl;
return View();
}
var claims = new List<Claim>
{
new Claim(ClaimTypes.Name, username),
new Claim(ClaimTypes.Role, "User")
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal);
// ReturnUrlが外部サイトだとオープンリダイレクトになるため、必ずローカル判定
if (!Url.IsLocalUrl(returnUrl))
{
return RedirectToAction("Index", "Home");
}
return LocalRedirect(returnUrl);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return RedirectToAction("Index", "Home");
}
private bool AuthenticateUser(string username, string password)
{
// 例: DBのパスワードハッシュを照合する
return true;
}
}
MVCのログインView例(送信先とmethodを明示)
MVCでは、フォームの送信先がどのアクションかを明確にするために、タグヘルパー(asp-action/asp-controller)を使うと事故が減ります。ReturnUrlはhiddenで引き回し、POST側でUrl.IsLocalUrlの判定とセットで扱うのが安全です。
@{
var returnUrl = ViewData["ReturnUrl"] as string;
}
<form asp-action="Login" asp-controller="Account" method="post">
@Html.AntiForgeryToken()
<div>
<label>ユーザー名</label>
<input name="username" />
</div>
<div>
<label>パスワード</label>
<input name="password" type="password" />
</div>
<input type="hidden" name="returnUrl" value="@returnUrl" />
<button type="submit">ログイン</button>
</form>
もしこのViewがPOSTした先に[HttpPost] Loginが存在しない(GETしかない)場合、まさに今回のように405が返りやすくなります。逆に、POSTしかないのにフォームがGETで送っている場合も同様です。
Cookie認証ミドルウェアの設定例(Program.cs)
Identityを使わないCookie認証でも、認証の土台(Authentication/Authorizationのミドルウェア)は必須です。設定が欠けると、405ではなく「常に未認証扱い」になったり、保護ページが素通りしてしまったりと別のトラブルになります。
using Microsoft.AspNetCore.Authentication.Cookies;
var builder = WebApplication.CreateBuilder(args);
// MVCの場合
builder.Services.AddControllersWithViews();
// Cookie認証
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/Account/Login";
options.AccessDeniedPath = "/Account/AccessDenied";
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
// セキュリティ設定の一例
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Lax;
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
// 重要:順番も大事(UseRoutingの後、Mapの前)
app.UseAuthentication();
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();
Razor Pagesの場合はAddRazorPages()とMapRazorPages()が必要になります。MVCとRazor Pagesを混在させることもできますが、その場合は「どのURLをどちらが担当するか」を明確にしておくと、405や404の混乱が減ります。
HTTP 405の原因を最短で切り分けるチェックリスト
405の解決は「どのURLに、どのメソッドで飛んでいるか」を確定させることから始まります。慣れると数分で原因に辿り着けます。
ブラウザ(Networkタブ)で最初に見るポイント
- Request URL:本当に想定のURLに送っているか(例:
/Account/Login) - Request Method:POSTのつもりがGETになっていないか
- Status Code:405の前に302が挟まっていないか(リダイレクト先で405になっているケース)
- Response Headers:
Allowヘッダーがあれば、許可メソッドが分かる
症状→原因→対処の早見表
| 症状 | ありがちな原因 | 対処 |
|---|---|---|
| POSTした瞬間に405。AllowがGET | サーバー側にPOSTハンドラーがない(MVCで[HttpPost]がない、Razor PagesでOnPostがない) | POSTアクション/OnPostAsyncを追加し、フォームの送信先を正しいエンドポイントへ |
| GETで送ってしまい405。AllowがPOST | method="post"がない、またはフォームが別URLにGETしている | フォームにmethod="post"を明示し、送信先(asp-page/asp-action)を修正 |
| サンプル通りに作ったのに405 | サンプルの前提(Razor Pages)と自分のアプリ(MVC)が違う | Razor Pagesならページとして実装、MVCならControllerに書き換える |
| POST先のURLが思っていたものと違う | タグヘルパーの指定ミス、ベースパス/リバースプロキシ配下でのパスずれ | Networkで実URLを確認し、asp-action/asp-controllerなどを明示 |
サーバー側ログで「どのエンドポイントにマッチしたか」を見る
ブラウザでURLとメソッドが分かっても、実際にサーバーがどのエンドポイントにマッチさせたかが不明な場合があります。開発環境ではログレベルを上げ、ルーティングのログが見えるようにすると切り分けが速くなります。
- 「どのController/Actionが選択されたか」
- 「Razor Pagesのどのページが選択されたか」
- 「メソッド制約で弾かれたか」
405は“存在するが許可されない”なので、ログ上は「エンドポイントは見つかったがHTTPメソッドが合わない」という形で現れることが多いです。
ReturnUrlの扱いで失敗しないための注意点
ログイン後に元のページへ戻すためにreturnUrlを使うのは王道ですが、扱いを誤ると脆弱性や不自然な挙動につながります。
- ReturnUrlは必ずローカルURLか検証:外部サイトへ誘導できるとオープンリダイレクトになるため、MVCなら
Url.IsLocalUrl、Razor PagesならLocalRedirectを基本にする - 二重送信に備える:クエリ文字列で来る場合とhiddenで来る場合がある。POST側でフォールバックを入れる
- 未ログイン→ログイン→戻るの流れを実機で確認:特にReverse Proxy配下ではReturnUrlが想定外になることがある
Identityなしで自前認証をするなら、ここだけは手を抜かない
Identityを導入しない理由は「既存のユーザー基盤がある」「シンプルにしたい」「UIを自作したい」など様々ですが、Identityが肩代わりしてくれていた安全策も自分で背負うことになります。最低限、次のポイントは設計段階で決めておくと安心です。
パスワードの扱い
- 平文保存は絶対にしない:必ずストレッチングされたハッシュ(PBKDF2/BCrypt/Argon2など)で保存する
- 比較は一定時間を意識:文字列比較の早期returnがタイミング攻撃の足がかりになることがあるため、ライブラリの検証関数を使う
- ログに出さない:例外やデバッグログに入力値を出さない
Cookieの設定
- HTTPS前提:
SecurePolicy.Alwaysを基本にし、開発以外でHTTPに流さない - HttpOnly:XSSでCookieが読まれるリスクを下げる
- SameSite:CSRFを緩和する(ただし要件によってLax/Strict/Noneを選ぶ)
- 有効期限と更新:
ExpireTimeSpanとSlidingExpirationで「いつ切れるか」を仕様として決める
ログイン試行とロックアウト
自前実装で抜けやすいのが「失敗回数制限」です。パスワード総当たり対策として、IPやユーザー単位で一定回数失敗したら時間を空ける、CAPTCHAを挟むなどの仕組みを用意しておくと、運用事故を減らせます。
よくあるハマりどころ(405以外も含む)
ログイン周りは405が解決しても、次の段階で別の“あるある”が出てきます。合わせてチェックしておくと、やり直しが少なくなります。
| 現象 | 原因の例 | 確認ポイント |
|---|---|---|
| ログインできたのに保護ページで未認証扱い | UseAuthentication()を呼んでいない、スキーム名が違う | Program.csのミドルウェア順序、CookieAuthenticationDefaults.AuthenticationSchemeの一致 |
| [Authorize]が効かない/効きすぎる | AddAuthorization()未設定、ポリシー/既定設定の誤り | Authorizationの登録、コントローラー/ページへの属性付与範囲 |
| ログイン画面に戻され続ける | Cookieが保存されない(SameSite/ドメイン/HTTPS)、ReturnUrlが外部扱い | Cookieの属性、ブラウザのCookie一覧、ReturnUrlの検証ロジック |
| POSTが400になる | CSRF対策トークン不足(AntiForgery) | MVCなら@Html.AntiForgeryToken()、Razor Pagesならformタグヘルパーの利用 |
まとめ:まず「Razor PagesかMVCか」を揃えると405は消える
HTTP 405(Method Not Allowed)は、ログイン処理そのものよりも「そのURLはPOSTを受け付ける設計になっているか?」という入口の問題で起きることが多いエラーです。特に、公式サンプルの前提(Razor Pages)と自分のプロジェクト(MVC)がズレていると、OnPostAsyncにPOSTしているつもりでも、実際はMVCのGETアクションにPOSTして405、という形になりがちです。
最初に次の3点を揃えるだけで、405の多くは解消できます。
- アプリの構造を決める(Razor Pagesなら
OnPostAsync、MVCなら[HttpPost]) - フォームのmethodと送信先を明示し、Networkタブで実際のリクエストを確認する
- Cookie発行(
SignInAsync)までの最小フローを作ってから、認証ロジックや権限を増やす
Identityを使わないCookie認証は自由度が高い分、前提のズレがそのまま不具合になります。今回のように「まずサンプルをそのまま動かす(Razor Pagesのプロジェクトで確認する)→考え方をMVCへ移植する」という順番を意識すると、迷いが減って実装が安定します。

コメント