紡Book の勉強ノートに戻る
技術ブログ2026.04.16

エラーコードはフロントの受け取り方に合わせる — JWT リフレッシュを 500 ではなく 401 に

コードレビューDjangoJWT

simplejwt の refresh token を扱うときに 500 を返してしまう典型パターンと、401 へ寄せる書き方の学習メモ。綴Photo では最初からこの形で書いている、という確認も兼ねている。


この記事でわかること

  • RefreshToken(raw) が無効入力で TokenError を投げる仕様
  • リフレッシュ系エンドポイントを 401 に寄せる例外ハンドリングのパターン
  • 「同じ目的の view が複数ある」ときの粒度合わせの大事さ

概念

Django の rest_framework_simplejwt は、無効・期限切れ・改ざんされた refresh token を RefreshToken(raw) に渡すと TokenError(または派生クラス InvalidToken)を投げます。

置き場所。refresh_token は Cookie。TokenError はメモリで保存しない。ブラウザ Cookierefresh_tokenhttpOnly に保存閉じても残るJWT の文字列読むサーバ到着時raw の文字列メモリ・保存しないTokenError はここに出るDB には書かない捕まえない → HTTP 500Django が返す。保存しない捕まえる → HTTP 401view が返す。保存しないRefreshToken は関所で、置き場所ではない。番号は HTTP 応答に乗るだけで、DB には書かない。

JWT の基礎(構造・署名・なぜ「持ってる」だけでは認証にならないか)は、別記事 Next.js ミドルウェアで JWT 検証をするかしないかの設計判断 の Callout でまとめている。用語が初耳ならそちらを先に読むと話の前提が揃う。

やりがちな書き方(これが 500 になる)

Cookie から refresh token を取って新しい access token を返すエンドポイントで、RefreshToken(raw) を try/except なしで直接呼ぶ例。下は説明用の最小例。

python
# 500 になる書き方(説明用の最小例)
class TokenRefreshCookieView(APIView):
    permission_classes = [AllowAny]
 
    def post(self, request):
        raw = request.COOKIES.get("refresh_token")
        refresh = RefreshToken(raw)   # ← raw が無効だと TokenError で 500
        access = str(refresh.access_token)
        return Response({"access": access})
リカバリはクライアントのメモリ。refresh_token は Cookie にある。401 の道API 応答 401保存しない番号だけが乗るブラウザ Cookierefresh_token を POST閉じても残るクライアントのメモリ成功なら再試行ここも 401 ならログインへ500 の道API 応答 500保存しない番号だけが乗るCookie は見ないクライアントのメモリリカバリに無い画面が止まる

なぜ危険か

  • 画面が無言で止まる: ユーザーには「なんかローディングが終わらない」体験しか見えません
  • 監視ノイズ: 期限切れの正常フローですら 500 として積まれ、本物の 500 が見えなくなります
  • セキュリティ調査の妨害: 改ざんされた refresh_token を投げるだけで 500 を誘発できるので、攻撃と混同しやすいです

401 に寄せる書き方

Cookie が空のケースも事前に弾きます。simplejwt を使うなら、refresh 系エンドポイントは最初からこの形で書いておくのが安全。

python
from rest_framework_simplejwt.exceptions import TokenError
 
class TokenRefreshCookieView(APIView):
    permission_classes = [AllowAny]
 
    def post(self, request):
        raw = request.COOKIES.get("refresh_token")
        if not raw:
            return Response(
                {"detail": "refresh token がありません。ログインしてください"},
                status=401,
            )
        try:
            refresh = RefreshToken(raw)
        except TokenError:
            return Response(
                {"detail": "refresh token が無効です。再ログインしてください"},
                status=401,
            )
        access = str(refresh.access_token)
        return Response({"access": access})

学びポイント

  • 同じ目的の view が複数あるなら、例外ハンドリングの粒度を揃えます。token を扱う view が複数あって片方だけ try/except だと「こっちは 401、あっちは 500」が混在し、フロントのリカバリが片側で破綻します
  • JWT 周りは「期限切れ」「署名不一致」「そもそも Cookie がない」の 3 ケースを必ず 401 に寄せます。500 は「想定外のバグ」だけのために取っておきます
  • サーバーのステータスコードは、クライアントのリカバリを前提に設計します。サーバー単体で見てはダメ

おわりに

「ハッピーパス(=全部うまくいく前提の道。異常系・エラーケースの逆)だけ書いて、例外や境界が抜ける」のは外部入力を扱うコードの典型的な穴です。RefreshToken のように外部入力を直接渡すコードは、起きうる例外を一度公式ドキュメントで洗い出してから書く のが安全。綴Photo の refresh 系エンドポイントは最初からこの TokenError を捕まえる形で書いていて、今回は「なぜそう書くか」を改めて言語化した記録になります。

クライアントとサーバーは「ステータスコードという契約」で繋がっています。契約を書き換えるときは両側を同時に見ます。


この記事は2026年4月時点の自分の理解で書いています。rest_framework_simplejwt の例外仕様はバージョンで挙動が変わることがあるので、最新の公式ドキュメントも合わせて参照してください。