401
リクエストが使える認証情報を運んでいませんでした。理由句は Unauthorized ですが、実際に表しているのは「未認証」の状態で、しかも何を出せば通るのかを示すヘッダーの添付は必須です。
同じ 3 桁でも、リクエストのどちら側にいるかで別々の問題になります。自分に当てはまるブロックを読んでください。
これが出ている理由
定番の形は、curl や API クライアントでは通るのにデプロイしたページからは 401 になる、というものです。これはほぼ確実に、認証情報がそもそもブラウザを出ていません。`fetch` の `credentials` の既定値は `same-origin` なので、オリジンを跨ぐ呼び出しは明示しない限り Cookie を一切送らず、自分のドメインでは完璧に動いていたセッションが、API を別ドメインへ移した瞬間に消えます。2 つ目の形は、出ていったが古いとして拒否されたトークンです。JWT の `exp` は秒単位の NumericDate (RFC 7519) なので、ミリ秒のタイムスタンプを書き込むとおよそ 5 万年先になり、ミリ秒で発行して秒で検証するトークンはランダムに見える形で落ちます。3 つ目はログではなく画面に出ます。ユーザー名とパスワードを尋ねるブラウザのダイアログがアプリの上に現れたなら、サーバーが `Basic` チャレンジで答え、ブラウザは仕様どおりに振る舞っただけです。
やること
コードが送るつもりのヘッダーではなく、ブラウザが実際に送ったリクエストヘッダーをネットワークパネルで読み、失敗しているそのリクエストに `authorization` かセッション Cookie が載っているかを確認してください。次に応答の `www-authenticate` を読みます。§15.5.2 が必須としているヘッダーで、最初のトークンがスキーム、Bearer なら `error` パラメータが「無い・壊れている・期限切れ・失効」のどれなのかを教えます。サーバー側を一切触らずに答えが手に入ります。オリジンを跨ぐ Cookie セッションなら、`credentials: "include"` を指定したうえで、サーバーが `Access-Control-Allow-Credentials: true` と具体的なオリジンを返しているかを確かめてください。認証情報を伴う場合にワイルドカードは使えません。そして再試行に上限を設けること。リフレッシュは 1 回試して諦める。期限切れセッションに対するリフレッシュのループは、トークンのエンドポイントが 401 を 429 に変える典型的な経路です。
これが出ている理由
認証情報がそもそも届かなかったか、届いたうえで検証側が拒んだかのどちらかです。ステータスコードにはその区別が乗らないので、まず消し込む価値があるのは中間の経路です。nginx は既定で `underscores_in_headers` が off のため、名前にアンダースコアを含むリクエストヘッダーを捨てます。クライアントが `X_Auth_Token` を送っても、アプリケーションが読む頃にはヘッダーごと消えています。Apache にも自分版があります。`CGIPassAuth` を有効にしない限り `Authorization` は CGI や FastCGI のアプリケーションへ渡されません。同じ PHP アプリケーションが、あるサーバーでは認証できて別のサーバーではできない理由がこれです。ヘッダーが届いている場合、拒否の原因はいくつかに集中します。片側だけローテーションされた署名鍵、検証側の期待と一致しない issuer や audience のクレーム、そして `exp` や `nbf` の検査が現役のトークンを弾くほどずれたホストの時計です。
やること
「認証情報が提示されなかった」と「認証情報が提示され、X の理由で拒否した」を別々の行としてログに残してください。デバッグのほとんどはこの区別そのもので、ステータスコードがそれを運ぶことは永遠にありません。自分が出すすべての 401 に `WWW-Authenticate` チャレンジが載っていることを確かめます。§15.5.2 の要求です。Bearer の API なら RFC 6750 のパラメータ(`Bearer error="invalid_token", error_description="..."`)を使えば、クライアントはサポートに問い合わせずに動けます。ブラウザから呼ばれる API から `Basic` チャレンジを送ってはいけません。ブラウザが自前の認証ダイアログでアプリケーションを中断します。次にヘッダーがホップを越えて生き残っているかを証明します。プロキシとアプリケーションの両方でヘッダーの有無をログに出し、トークンライブラリを疑う前に `underscores_in_headers` と `CGIPassAuth` を確認してください。最後に時計です。検証するホストで `timedatectl` か `chronyc tracking` を見ます。ずれは断続的な 401 を生み、認証のコードをいくら読んでも説明がつきません。
これが出ている理由
サイトがあなたが誰かを尋ねているか、それまで持っていた答えではもう足りないと判断したかのどちらかです。実際には、時間切れになったセッション、別のタブや別の端末で行われたサインアウト、どこかで変更されたパスワード、あるいはアクセス期限付きだったメールや共有ドキュメントのリンクが切れた、というあたりです。端末やブラウザや回線の不調ではありませんし、「権限がありません」と言われているのとも違います。これはサイトが「今のあなたが誰か分からない」と言っている状態です。
やること
まずサインインし直してください。ほとんどの場合それで直ります。すでにサインイン済みなのに出るなら、意図的にサインアウトしてから入り直します。古いセッションはサイト側にあるので、新しいサインインでしか置き換わりません。小さなブラウザのダイアログがユーザー名とパスワードを尋ねてきたら、それは怪しいものではなくサイト自身の保護で、求めているのはそのサイトが発行した認証情報です。メールアドレスや端末のパスワードは絶対に入れないでください。そのサイトの Cookie を消すのは、それが本当に効く数少ない場面の一つです。期限切れのセッションは Cookie の中に入っているからです。誰かが共有したリンクから来たのなら、新しいリンクをもらってください。アクセスを与えるリンクはたいてい、意図的に期限が切れます。
失敗した URL に対してそのまま実行してください。エラーページを表示せずにステータスだけを出すので、ブラウザが描画したものではなくサーバーが言ったことが見えます。
curl -sS -o /dev/null -D - -w '\n%{http_code}\n' https://example.com/api/me`-sS` は進捗表示だけを消してエラーメッセージは残し、`-o /dev/null` はエラーページの本文を捨て、`-D -` は応答ヘッダーを標準出力に落とし、`-w` は最後にステータスを 1 行で出します。目当てのヘッダーは `www-authenticate` です。§15.5.2 が 401 に必須としているもので、最初のトークンがスキーム、Bearer の API なら `error` パラメータが「トークンが無い」「不正」「期限切れ」を分けます。このヘッダーが無ければ、そのサーバーは仕様どおりに答えておらず、以後の情報源はそのサーバー自身のログだけになります。次に、ステータスコードでは分けられない 2 つの場合を切り分けるため、同じコマンドをあと 2 回実行します。1 回は `-H 'authorization: Bearer <token>'` を付けて、もう 1 回は `-u wrong:creds` を付けて。両方 401 なら検証側が何もかも拒んでいるということで、疑うべきはあなたのトークンではなく鍵か issuer か audience です。本物のトークンを送ったときだけ 401 なら、そのトークンが期限切れか失効しています。なお `-u` は Basic の認証情報を先回りして送るので、その実行が 401 になったこと自体は、サーバーが Basic を提示したかどうかについて何も語りません。それを語るのは `www-authenticate` だけです。チャレンジがサイトではなくプロキシを名乗っているなら、見るべきは 407 のほうです。
RFC 9110 §15.5.2 は 401 を、対象リソースに対する有効な認証情報を欠いているためリクエストが適用されなかった状態と定義し、そこに強い要求を付けています。401 を生成するサーバーは、少なくとも 1 つのチャレンジを含む `WWW-Authenticate` ヘッダーフィールドを送らなければならない (MUST)、と。このヘッダーこそが 401 と 403 を分けるすべてです。違う認証情報で再試行すれば成功しうるからこそ存在するもので、サーバーは「ではどの認証情報なのか」を機械可読な形でクライアントに返す義務を負います。チャレンジの無い 401 は仕様違反であり、同時にいちばん午後を溶かすタイプの 401 でもあります。サーバーが何を求めていたのかを語る唯一のフィールドが消えているからです。同じ節は、意外に思われがちな場合も扱っています。リクエストが認証情報を含んでいた場合、401 はその認証情報に対して認可が拒否されたことを意味します。つまり 1 つのステータスコードが「何も送らなかった」と「送ったが拒否された」の両方を覆っていて、ステータス行にはそれを分ける情報がありません。だからあなた自身のログで分けるしかないのです。§15.5.2 はさらに、リクエストを繰り返して同じチャレンジが返ってきたユーザーエージェントに対し、応答ボディを利用者に見せるよう求めています。診断上の詳細はたいていそこに載っているからです。見る場所を変える事実があと 2 つあります。§11.3 のチャレンジ文法はスキーム名とパラメータの並びで、そのうち `realm` が保護空間に名前を与えます。そしてブラウザの挙動を決めるのはスキームです。`Basic` チャレンジ (RFC 7617) を返すと、ブラウザはあなたのアプリケーションの上に自前のユーザー名とパスワードのダイアログを描きます。トークン API については、RFC 6750 §3.1 が好みに任せずステータスで場合分けを規定しています。`invalid_request` は 400、`invalid_token` は 401、`insufficient_scope` は 403 です。この読み方に従えば、OAuth で保護された API が返す 401 は「トークンが無い・壊れている・期限切れ・失効した」のいずれかであり、本人のものではあるが権限の範囲が足りないトークンは 403 で返ってくるべきだったことになります。最後に、401 は RFC 9110 §15.1 がヒューリスティックにキャッシュ可能としているステータスコードには入っていないので、プロキシが独断でキャッシュすることはありません。
| 紛らわしいコード | 見分け方 |
|---|---|
| 403 | 401 は「あなたが誰か分からない」、403 は「分かったうえで、それでも駄目だ」です。信頼できる判定材料は言葉づかいではなくヘッダーです。RFC 9110 が 401 に `WWW-Authenticate` を要求するのは、違う認証情報で再試行すれば通りうるからで、403 に同種の要求が無いのは通りえないからです。RFC 6750 §3.1 も OAuth で同じ線を引いています。トークンが無い、あるいは不正なら `invalid_token` で 401、有効だが必要なスコープを欠くなら `insufficient_scope` で 403 です。 |
| 404 | 存在すると確信しているリソースが、未認証の呼び出し元には正当に 404 を返すことがあります。§15.5.4 は、禁止されたリソースの存在自体を認めたくないサーバーが代わりに 404 を送ることを許していて、非公開リポジトリやオブジェクトストレージはまさにそれをやります。だから 404 をルーティングのバグとして追い始める前に、認証情報を付けて試してください。200 や 403 に変われば、最初から変装したアクセス制御の判断だったということです。 |
| 400 | サーバーがそもそもパースできない `Authorization` ヘッダーは、拒否された身元ではなく壊れたリクエストです。RFC 6750 §3.1 も `invalid_request` には 401 ではなく 400 で答えるべきだと明記しています。実際のフレームワークは足並みが揃っていないので、認証が要るエンドポイントで出た 400 は、トークンそのものが拒否されたと決めつける前に「ヘッダーの形が違う」と読む価値があります——`Bearer ` の接頭辞が無い、余計な改行が混ざっている、`Authorization` が 2 本ある、など。 |
407 | 407 (Proxy Authentication Required, §15.5.8) は同じ発想を 1 ホップ手前でやったものです。認証情報を求めているのはオリジンではなくプロキシで、チャレンジも `WWW-Authenticate` ではなく `Proxy-Authenticate` で来ます。社内ネットワークで説明のつかない 401 は、よく読んでいない 407 であることがよくあります。見分け方は、いま触っている API だけでなく、試すホスト全部でチャレンジが返ってくることです。 |
Basic | Basic スキーム (RFC 7617) は `user:password` を base64 にしますが、これは符号化であって暗号化ではありません。ヘッダーが見える者は誰でもパスワードを読めるので、安全なのは TLS の上だけです。それに、意図されることの少ない UI 上の副作用もあります。`Basic` チャレンジを受け取ったブラウザは、ページの上に自前の認証ダイアログを描きます。JavaScript から呼ばれる前提の API が `Bearer` でチャレンジすべき理由がこれです。 |
どれもブラウザ内で完結します。アップロードは発生しません。
401 を調べているとき、ついでに読むことになりがちなコードです。
ページではなく応答ヘッダーを読んでください。curl -sS -o /dev/null -D - https://example.com/path が本文なしでヘッダーだけを出し、server・via・cf-ray の 3 つで、答えたのがどの層かが分かります。cf-ray の値があれば応答を扱ったのは Cloudflare で、その値はサポートが聞いてくる ID です。エッジを完全に外して確かめたいなら、--resolve example.com:443:203.0.113.10 を付けて同じリクエストを投げ直します。元のホスト名と SNI を送りつつ、指定したオリジンのアドレスへ接続するので、答えが変われば、エッジとオリジンの言い分が食い違っているということです。
ありません。分岐条件にしてはいけません。RFC 9112 はクライアントに理由句を無視するよう求めていますし、サーバーは自由に変えられますし、HTTP/2 と HTTP/3 にはそもそも理由句が存在しません。HTTP/1.1 では 404 Not Found として届くものが、HTTP/2 では素の 404 として届きます。このサイトが登録済みの理由句を載せているのは、それが検索される語であり HTTP/1.1 のログに出る文字列だからで、何かのソフトウェアがそれに依存しているからではありません。
5xx と 429 は再試行してよく、4xx はしないでください。次に投げても中身は同じだからです。応答に Retry-After が付いていればそれに従ってください。RFC 9110 がまさにこのために定義していて、値は秒数でも日時でも構いません。付いていなければ、ジッター付きの指数バックオフと明確な上限を使い、しかも冪等なメソッドに限ってください。再試行された POST はカードに二重で課金しかねません。すでに失敗しているサーバーへの再試行の嵐は、短い障害を長い障害に変える典型的な経路です。
変えられます。守るべき規則は 1 つだけ、そのコードが本当であること。応答を自動で読むもの——検索エンジン、監視、キャッシュ、クライアント側の再試行ロジック——はすべて数字だけで判断し、ページの中身は一切見ません。エラーページを 200 で返せば自分の監視から障害が隠れますし、無いページを 200 で返せばエラーが本文として索引されますし、単に不正なリクエストに 500 を返せば、当番の人をスタックの間違った半分へ送り込むことになります。
401 でまだ詰まっているなら、ステータスコード一覧をすべて見る。あるいは上に戻って、自分の立ち位置に向けて書かれたブロックを読んでください。