401
這個請求沒有帶上可用的認證憑證——原因片語寫的是 Unauthorized,但它描述的狀況其實是「沒認證」,而且伺服器有義務用一個標頭告訴你什麼樣的憑證才行。
同樣三個數字,你站在請求的哪一邊,它就是不同的問題。直接讀你自己那一塊。
為什麼會看到它
最典型的樣子是:在 curl 或 API 用戶端裡跑得好好的,從部署好的頁面打就 401,而這幾乎都代表那份憑證根本沒離開瀏覽器。`fetch` 的 `credentials` 預設是 `same-origin`,所以跨來源的呼叫在你沒開口要之前,一個 cookie 都不會送——於是一個在自家網域上完全正常的 session,在 API 搬到別的網域的那一刻就消失了。第二種樣子是憑證送出去了、卻因為太舊被拒絕:JWT 的 `exp` 是以秒為單位的 NumericDate(RFC 7519),所以一個寫成毫秒的時間戳大約是五萬年後,而一個用毫秒簽發、用秒去檢查的 token,失敗的方式看起來會像隨機的。第三種不會出現在紀錄裡,只會出現在畫面上——一個要你輸入帳號密碼的瀏覽器對話框蓋在你的 app 上面,那代表伺服器回了一個 `Basic` 挑戰,而瀏覽器照規格做了它該做的事。
該怎麼做
去網路面板讀瀏覽器「實際送出」的請求標頭,不要讀你程式碼「打算送」的那些,並且明確確認 `authorization` 或 session cookie 有出現在失敗的那個請求上。接著讀回應的 `www-authenticate` 標頭,§15.5.2 規定它是必要的:它的第一個 token 是 scheme,而在 Bearer 上,`error` 參數會告訴你到底是沒帶、格式錯、過期還是被撤銷——不用改伺服器任何一行就能拿到答案。跨來源的 cookie session 要傳 `credentials: "include"`,並且確認伺服器回的是 `Access-Control-Allow-Credentials: true` 加上一個具體的來源,因為帶憑證時不允許用萬用字元。還有,重試次數要設上限:更新一次不成就放棄,因為對一個已經過期的 session 一直重試更新,正是把 401 變成 429 的標準做法。
為什麼會看到它
不是憑證根本沒送到,就是送到了、被你的驗證器拒絕——而既然狀態碼分不出這兩者,最該先排除的嫌疑犯就是中間那一層。nginx 會把名稱裡有底線的請求標頭丟掉,因為 `underscores_in_headers` 預設是關的,所以用戶端送的 `X_Auth_Token`,到應用程式讀的時候就是不見了。Apache 有它自己的版本:`Authorization` 除非打開 `CGIPassAuth`,否則不會被傳給 CGI 和 FastCGI 應用程式——這就是為什麼同一份 PHP 應用程式在某台伺服器上認得出身分、在另一台上認不出。等標頭真的有送到,拒絕的原因就集中在幾種:簽章金鑰只在其中一邊輪替了、issuer 或 audience 宣告跟驗證器期待的對不上,還有主機時鐘漂移到讓 `exp` 或 `nbf` 檢查把完全有效的 token 打回票。
該怎麼做
把兩種情況記成兩行不同的紀錄——「沒有出示憑證」和「出示了憑證,因為 X 被拒絕」——因為光是這個區分就佔了除錯的大半,而狀態碼永遠不會替你帶上它。確認你送出的每個 401 都帶著 `WWW-Authenticate` 挑戰,§15.5.2 要求如此;Bearer API 請用 RFC 6750 的參數(`Bearer error="invalid_token", error_description="..."`),這樣用戶端不用開客服單就能處理。不要從一個瀏覽器會呼叫的 API 送 `Basic` 挑戰,否則瀏覽器會用它自己的憑證對話框打斷你的應用程式。接著證明標頭有活著走完那一跳:在代理和在應用程式各記一次它在不在,並且在你跑去翻 token 函式庫之前,先去看 `underscores_in_headers` 或 `CGIPassAuth`。最後,去看時鐘——在做驗證的那台主機上跑 `timedatectl` 或 `chronyc tracking`——因為時鐘偏移產生的是斷斷續續的 401,你把認證程式碼讀爛了也解釋不出來。
為什麼會看到它
網站在問你是誰,或者它認為手上那個答案已經不夠用了。實務上就是:session 逾時了、你在另一個分頁或另一台裝置上登出了、密碼在別的地方被改掉了,或者那條信件、共用文件裡的連結本來就有時效、而時效到了。這不是你的裝置、瀏覽器或連線的問題,也不等於「你不被允許」——這一個是網站在說:我現在不認得你。
該怎麼做
先重新登入一次,多數情況這就是解法。如果你明明已經登入還是看到它,就刻意先登出再登入,因為過期的 session 存在網站那一端,只有一次全新的登入才會把它換掉。如果跳出來的是一個小小的、要帳號密碼的瀏覽器對話框,那是網站自己的保護機制、不是什麼惡意的東西,而它要的是那個網站發給你的帳號密碼——絕對不是你的電子郵件密碼或裝置密碼。清掉那一個網站的 cookie,是少數真的有用的情況之一,因為過期的 session 就存在裡面。而如果你是從別人分享的連結進來的,就請他重新給一條:會授權的連結通常是故意會過期的。
對著出錯的那個網址跑一次。它只印狀態碼、不印錯誤頁,所以你看到的是伺服器真正說了什麼,而不是瀏覽器畫出來的東西。
curl -sS -o /dev/null -D - -w '\n%{http_code}\n' https://example.com/api/me`-sS` 會把進度列拿掉但留下錯誤訊息,`-o /dev/null` 把錯誤頁的內容丟掉,`-D -` 把回應標頭印到標準輸出,`-w` 則在最後單獨印一行狀態碼。你要找的那個標頭是 `www-authenticate`:§15.5.2 規定 401 上一定要有它,它的第一個 token 是 scheme,而在 Bearer API 上,`error` 參數會把「沒帶」、「不合法」和「過期」分開。如果那個標頭根本不在,代表伺服器沒照規格回答,那你就只剩它自己的紀錄可以查了。接著把同一行再跑兩次,去切開狀態碼分不出的那兩種情況:一次加 `-H 'authorization: Bearer <token>'`,一次加 `-u wrong:creds`。兩次都 401,代表驗證器什麼都拒絕,問題指向金鑰、issuer 或 audience,不是你的 token;只有送真 token 那次是 401,就指向那個 token 過期或被撤銷了。要注意 `-u` 會讓 curl 主動先送 Basic 憑證,所以那一次拿到 401,完全不能證明伺服器有提供 Basic——只有 `www-authenticate` 標頭能說。如果挑戰指的是代理而不是網站,那要改看 407。
RFC 9110 §15.5.2 把 401 定義成:這個請求沒有被執行,因為它缺少對目標資源有效的認證憑證;並且附了一條硬性要求——產生 401 的伺服器「必須」送出一個 `WWW-Authenticate` 標頭欄位,裡面至少帶一個挑戰。那個標頭就是 401 和 403 的全部差別:它存在的理由是「換一組憑證重試有可能會成功」,所以伺服器欠用戶端一句機器讀得懂的話,說清楚是哪一種憑證。一個沒有挑戰的 401 是違反規格的,同時也是最會吃掉你下午時間的那個版本,因為它把唯一寫著「伺服器要什麼」的欄位拿掉了。同一節也涵蓋了一個大家常覺得意外的情況:如果請求「有」帶憑證,那這個 401 的意思是那組憑證被拒絕了授權。所以同一個狀態碼同時蓋住「你什麼都沒送」和「你送了、但被打回票」,而狀態行裡沒有任何東西能分開這兩者——這就是為什麼得靠你自己的紀錄去分。§15.5.2 還交代,用戶端如果重送之後拿回同一個挑戰,應該把回應的內容顯示給使用者看,因為診斷細節通常就寫在那裡。還有兩件事會改變你該去哪裡找。§11.3 的挑戰語法是一個 scheme 名稱後面接參數,其中 `realm` 指的是保護範圍,而 scheme 決定了瀏覽器的行為:一個 `Basic` 挑戰(RFC 7617)會讓瀏覽器在你的應用程式上面疊出它自己的帳號密碼對話框。而在 token API 這邊,RFC 6750 §3.1 直接用狀態碼把情況切開,不留給大家各憑喜好:`invalid_request` 是 400、`invalid_token` 是 401、`insufficient_scope` 是 403。這樣讀的話,一個 OAuth 保護的 API 回 401,代表 token 沒帶、格式壞掉、過期或被撤銷;而一個確實是你的、只是範圍不夠的 token,本來應該回 403 才對。最後,401 不在 RFC 9110 §15.1 列為可依啟發式規則快取的狀態碼裡,所以沒有代理會自作主張把它存起來。
| 容易搞混的 | 怎麼分 |
|---|---|
| 403 | 401 是「我不知道你是誰」,403 是「我知道,答案還是不行」。可靠的判斷不是看字面而是看標頭:RFC 9110 要求 401 一定要有 `WWW-Authenticate`,因為換一組憑證有可能成功;而 403 什麼都不要求,因為換了也沒用。RFC 6750 §3.1 在 OAuth 上畫了同一條線——token 沒帶或不合法是 `invalid_token`、回 401;token 有效但缺少必要的 scope 是 `insufficient_scope`、回 403。 |
| 404 | 一個你很確定存在的資源,對沒帶憑證的呼叫者回 404,是完全合法的:§15.5.4 允許一台不想承認某個被禁資源存在的伺服器改回 404,而私有版本庫和物件儲存正是這樣做的。所以在你把一個 404 當成路由 bug 去查之前,先帶著憑證重試一次——如果它變成 200 或 403,那它從頭到尾就是一個換了裝的存取決定。 |
| 400 | 一個伺服器根本解析不了的 `Authorization` 標頭,是格式壞掉的請求,不是被拒絕的身分,而 RFC 6750 §3.1 明講了:`invalid_request` 應該回 400,不是 401。實務上各家框架做法不一,所以在一個需要認證的端點上看到 400,值得先當成「你的標頭形狀不對」來讀——少了 `Bearer ` 前綴、夾了一個換行,或者送了兩個 `Authorization` 標頭——再去假設 token 本身被拒。 |
407 | 407(Proxy Authentication Required,§15.5.8)是同一個概念往前一跳:要憑證的是代理、不是來源機,而且它用 `Proxy-Authenticate` 挑戰,不是 `WWW-Authenticate`。在公司網路上,一個你解釋不了的 401 常常是一個你沒仔細讀的 407,而破綻是:那個挑戰對你試的每一台主機都出現,不是只出現在你在弄的那個 API 上。 |
Basic | Basic scheme(RFC 7617)是把 `user:password` 做 base64,那是編碼不是加密——任何看得到那個標頭的人都讀得出密碼,所以它只有在 TLS 上才安全。它還有一個大家很少是故意要的介面副作用:瀏覽器收到 `Basic` 挑戰時,會在頁面上疊出它自己的憑證對話框,所以一個要給 JavaScript 呼叫的 API 應該改用 `Bearer` 來挑戰。 |
這些全都在你的瀏覽器裡跑,不會上傳任何東西。
查 401 的時候,通常也會順手看一下這幾個。
去讀回應標頭,不要讀那個網頁。curl -sS -o /dev/null -D - https://example.com/path 會只印標頭、不印內容,而 server、via 和 cf-ray 三個加起來,就能指出是哪一層回答的。有 cf-ray 的值代表這個回應是 Cloudflare 處理的,那個值也是他們客服會跟你要的編號。想把邊緣節點整個排除掉,就加上 --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 可能會刷兩次卡。對一台已經在失敗的伺服器丟出重試風暴,是短暫事故變成長時間事故最常見的那條路。
可以,唯一要守的規則是那個代碼必須是真的。所有會自動讀你回應的東西——搜尋引擎、監控、快取、用戶端的重試邏輯——都只看那個數字,從來不看那一頁寫了什麼。用 200 回一頁錯誤訊息,會把故障從你自己的警報裡藏起來;用 200 回一頁不存在的內容,會讓錯誤被當成內容收進索引;而對一個只是格式不對的請求回 500,會把值班的人送去堆疊裡錯的那一半。
401 還是卡住嗎?看完整的狀態碼對照表,或是回到上面,讀為你這一端寫的那一塊。