401と403、その「境界線」を見誤っていませんか?
Web APIの設計や、フロントエンドとバックエンドを繋ぐインフラの構築をしていると、避けて通れないのがHTTPステータスコードのハンドリングだ。
特に、アクセス拒否を表す代表選手である「401 Unauthorized」と「403 Forbidden」。
君は、この2つの違いを自信を持って説明できるだろうか? 「アクセスできないんだから、どっちでも一緒だろ」なんてコードレビューで返していないだろうか。
実際の現場では、この2つを混同したまま設計・実装を進めた結果、フロントエンド側で「ログイン画面にリダイレクトさせるべきか、エラーモーダルを出すべきか」の判断がつかなくなり、ユーザーエクスペリエンス(UX)を盛大にブチ壊すという惨劇が後を絶たない。
パケットの向こう側で何が起きているのか。RFCの定義から、実際の通信フロー、そしてNginxやNode.js(Express)を用いた実装・設定の現場ノウハウまで、シニアエンジニアの視点で徹底的に紐解いていこう。
—
1. RFCが定義する「401」と「403」の決定的な違い
まずは、HTTPのバイブルであるRFC(RFC 9110)がこの2つのステータスコードをどう定義しているか、その本質を確認する。
401 Unauthorized —— 「誰だか分からないので、名乗ってください」
RFC 9110では、401を以下のように定義している。
> The 401 (Unauthorized) status code indicates that the request has not been applied because it lacks valid authentication credentials for the target resource.
ここで重要なのは、「Unauthorized」という名称でありながら、実際の本質は「Unauthenticated(未認証)」であるという点だ。
サーバーは「あなたが誰だか分からない。クレデンシャル(認証情報)を提示しなさい」と要求している状態であり、通常、応答ヘッダーには `WWW-Authenticate` が含まれる。
403 Forbidden —— 「誰だか分かったけど、そこに入る権限はないよ」
一方、403の定義はこうだ。
> The 403 (Forbidden) status code indicates that the server understood the request but refuses to authorize it.
こちらは、サーバーはリクエストを送ってきたクライアントの身元(誰であるか)を既に把握している(あるいは認証不要の公開ルートである)。しかしながら、そのリソースへアクセスする権限(Authorization / Permission)が不足しているため、拒否している状態だ。何度同じクレデンシャルを送っても、権限が昇格しない限り結果は変わらない。
—
2. 通信フロー(シーケンス)で見る挙動の差
言葉だけではピンとこないかもしれない。クライアント、リバースプロキシ(Nginx)、そしてバックエンドAPIの間で、パケットがどのようにやり取りされるのかシーケンスで追ってみよう。
パターンA:401 Unauthorized の世界線
ユーザーが未ログインの状態で、保護されたリソースにアクセスしたケースだ。
[Client] [Nginx / API Server] ユーザーはログイン済み(一般権限)だが、管理者専用のエンドポイントを叩いたケースだ。 [Client] [Nginx / API Server] ここからは、実務の現場で即座に使える具体的なコードと設定例を見ていく。 インフラレイヤーで特定のIPや、特定の内部フラグに基づいて403を返す設定だ。 server { location /api/v1/admin/ { # メンテナンス中や権限エラー時のカスタムJSONを返す設定 proxy_pass http://backend_cluster; /custom_403.json の実体や返却設定 現場のTips: APIサーバーの手前にNginxなどのリバースプロキシを置く場合、認証の手前(IP制限など)で弾く403と、アプリケーション層まで到達してロール不足で弾く403が混在する。ログ監視の際、どちらのレイヤーでブロックされたのか追跡できるようにエラーログのフォーマットを整えておくことが、障害切り分けの第一歩だ。 続いて、アプリケーション層(バックエンド)での厳密なステータスコードの振り分けロジックを見てみよう。JWT(JSON Web Token)を用いた認証・認可のミドルウェアのイメージだ。 const express = require(‘express’); // 1. 認証ミドルウェア (Authentication: 401を担保) if (!token) { // ここでトークンの検証(有効期限、署名など)を行うとする // 2. 認可ミドルウェア (Authorization: 403を担保) // ルートの定義 // 管理者専用エンドポイント (認証 + 管理者権限が必要) このコードの美しいところは、「誰が検証してもブレない関心の分離」がなされている点だ。`authenticateToken` は身元確認だけに徹し、`requireRole` は権利の有無だけに集中している。 設計したAPIが意図したステータスコードを返しているか、手元の端末から `curl` でスモークテストを行う際のコマンド例だ。 1. 認証トークンなしでアクセス -> 401が返るべき 2. 一般権限のトークンを使って管理者用エンドポイントにアクセス -> 403が返るべき `-i`(または `–include`)オプションをつけることで、HTTPレスポンスヘッダー(ステータス行を含む)が標準出力に表示される。レスポンスボディのJSONだけでなく、必ずステータスコード(`HTTP/1.1 401 Unauthorized` など)を目視で確認する習慣をつけよう。 — ネットワークのトラブルシューティングにおいて、HTTPステータスコードは現場の医師が聴診器を当てるようなものだ。 「401と403のどちらを返すべきか?」と迷ったときは、一度立ち止まってこう自問してほしい。 > 「今、目の前のリクエストを送ってきたやつの顔(身元)は、サーバーに見えているか?」 この原則をブレずにコードに落とし込むだけで、APIの品質は劇的に向上し、フロントエンドエンジニアやQAエンジニアからの無駄な問い合わせを激減させることができる。 さあ、今日のデプロイから、正確で美しいステータスコードを返していこう。
| |
|—- GET /api/v1/profile ——–>| (認証情報なし)
| |
|<--- 401 Unauthorized ------------| (おい、身分証を見せろ)
| WWW-Authenticate: Bearer |
| |
| (クライアントがログイン画面へ誘導) |
| (認証後、トークンを取得して再挑戦) |
| |
|---- GET /api/v1/profile -------->| (Authorization: Bearer
|<--- 200 OK ----------------------| (成功!)
パターンB:403 Forbidden の世界線
| |
|—- GET /api/v1/admin/users —->| (一般ユーザーのトークンを付与)
| |
| (サーバー側でトークン検証) |
| (身元は判明: user_id = 123) |
| (権限チェック: admin = false) |
| |
|<--- 403 Forbidden ---------------| (お前は一般人だ、通せんぼ)
| |
| (クライアントは「権限不足」モーダルを表示)
このフローの違いを見れば、なぜ「401と403を正しく使い分けなければならないのか」が痛感できるはずだ。401なら「再ログイン」で解決するが、403で再ログインさせたらユーザーは発狂する。
---
3. 実務で役立つ設定・実装サンプル
① Nginxでのアクセス制御とカスタムエラー(403の演出)
listen 80;
server_name api.example.com;
# 特定の社内IPレンジ以外からのアクセスは問答無用で403を返す
allow 192.168.10.0/24;
deny all;
error_page 403 /custom_403.json;
}
}
location = /custom_403.json {
internal;
default_type application/json;
return 403 ‘{“error”: {“code”: 403, “message”: “Access Denied: Administrator privileges required.”}}’;
}② Node.js (Express) によるミドルウェア実装
const app = express();
const authenticateToken = (req, res, next) => {
const authHeader = req.headers[‘authorization’];
const token = authHeader && authHeader.split(‘ ‘)[1]; // “Bearer
// トークンすらない = 誰だか分からないため 401
return res.status(401).json({
error: “Unauthorized”,
message: “認証トークンが存在しません。ログインしてください。”
});
}
verifyToken(token, (err, user) => {
if (err) {
// トークンはあるが、偽造されている、または期限切れ = 401
return res.status(401).json({
error: “Unauthorized”,
message: “無効な認証トークンです。”
});
}
req.user = user; // ユーザー情報をリクエストオブジェクトに格納
next();
});
};
const requireRole = (requiredRole) => {
return (req, res, next) => {
// この時点で req.user は authenticateToken を通過しているので「誰だか分かっている」
if (!req.user || req.user.role !== requiredRole) {
// 身元は割れているが、要求されたロールを持っていない = 403
return res.status(403).json({
error: “Forbidden”,
message: “このリソースにアクセスする権限がありません。”
});
}
next();
};
};
// 一般ユーザー用エンドポイント (認証のみ必要)
app.get(‘/api/v1/profile’, authenticateToken, (req, res) => {
res.json({ message: `ようこそ、${req.user.name}さん` });
});
app.get(‘/api/v1/admin/dashboard’, authenticateToken, requireRole(‘admin’), (req, res) => {
res.json({ message: “機密性の高い管理者ダッシュボードデータです。” });
});③ フロントエンド / デバッグ時の確認手法 (cURL)
curl -i -X GET https://api.example.com/api/v1/profile
curl -i -X GET https://api.example.com/api/v1/admin/dashboard \
-H “Authorization: Bearer eyJhbGciOiJIUzI1NiIsIn…”4. シニアから次世代エンジニアへ送る教訓
コメント