axios interceptorでログイン401エラーが表示されない問題を3ステップで解決

axios interceptorでログイン401エラーが表示されない問題を3ステップで解決

React + axios でJWT認証を実装していて、「ユーザーが間違ったパスワードを入力しても、エラーメッセージが表示されない」という問題に遭遇していませんか?開発者ツールを見ると、なぜか「リフレッシュトークンが見つかりません」というエラーが出ている…。

これはaxios interceptorが全ての401エラーを「トークン期限切れ」と誤判断してしまう、非常に典型的な認証実装の落とし穴です。ログイン時の401エラーは「認証情報が間違っている」という意味なのに、interceptorが勝手にリフレッシュトークン処理を実行してしまいます。

本記事では、この問題を3ステップで解決する方法と、同様の問題を防ぐための設計パターンを詳しく解説します。

目次

問題の症状:ログイン失敗時にエラーメッセージが出ない

まず、この問題がどのように発生するかを具体的に見てみましょう。以下のような状況で問題が現れます:

  • ユーザーが間違ったパスワードを入力
  • ログインボタンをクリック
  • 画面に何のエラーメッセージも表示されない
  • ユーザーは「なぜログインできないの?」と困惑

開発者ツールのコンソールを確認すると、以下のようなエラーが出力されています:

❌ 期限切れトークン → 401エラー → リフレッシュ試行 → リフレッシュトークンが見つかりません → エラー非表示

本来であれば「パスワードが間違っています」といったユーザーフレンドリーなメッセージが表示されるべきなのに、interceptorが横取りしてしまい、Login.tsxにエラー情報が届かない状態になっています。

典型的なエラーフロー

問題のあるフローを図解すると以下のようになります:

  1. ユーザーが間違ったパスワードを入力
  2. バックエンドが401エラーを返す(正常な動作)
  3. api.tsのinterceptorが401をキャッチ
  4. 「トークン期限切れ」と誤判断(ここが問題)
  5. リフレッシュトークン処理を実行
  6. 「リフレッシュトークンが見つかりません」エラー
  7. Login.tsxに元の401情報が届かない(ユーザーは混乱)

根本原因:interceptorが全ての401を「トークン期限切れ」と誤判断

この問題の根本原因は、axios interceptorが401エラーの文脈を理解せずに、すべての401を「認証トークンの期限切れ」として処理してしまうことにあります。

2種類の401エラーの違い

実際には、401エラーには大きく分けて2つの種類があります:

  • ログイン時の401:「ユーザー名・パスワードが間違っている」
  • API呼び出し時の401:「認証トークンが期限切れ・無効」

前者はユーザーにエラーメッセージを表示すべきですが、後者は自動的にリフレッシュトークンで再認証を試行すべきです。しかし、多くの実装ではこの区別ができていません

問題のあるinterceptor実装例

以下は典型的な問題のあるinterceptor実装です:

// ❌ 問題のある実装
axios.interceptors.response.use(
  (response) => response,
  async (error) => {
    // すべての401エラーで同じ処理をしてしまう
    if (error.response?.status === 401) {
      try {
        // ログイン時の401でもリフレッシュを試行してしまう
        await refreshToken();
        return axios.request(error.config);
      } catch (refreshError) {
        // リフレッシュ失敗でログアウト処理
        logout();
        return Promise.reject(refreshError);
      }
    }
    return Promise.reject(error);
  }
);

この実装では、ログインエンドポイントからの401エラーも「トークン期限切れ」として扱われ、存在しないリフレッシュトークンでの再認証を試行してしまいます。

【3ステップ解決法】エンドポイント別の401エラーハンドリング

この問題を解決するために、エンドポイント別に401エラーの処理を分岐させる実装パターンを紹介します。

Step 1: ログインエンドポイントを除外リストに追加

まず、リフレッシュトークン処理を行わないエンドポイントのリストを定義します:

// src/api/config.ts
export const NO_REFRESH_ENDPOINTS = [
  '/auth/login',
  '/auth/register', 
  '/auth/refresh',
  '/auth/forgot-password'
];

// URLが除外リストに含まれるかチェックする関数
export const isNoRefreshEndpoint = (url: string): boolean => {
  return NO_REFRESH_ENDPOINTS.some(endpoint => url.includes(endpoint));
};

Step 2: エラーレスポンスの適切な伝播設定

interceptorで条件分岐を行い、ログイン関連エンドポイントの401エラーはそのまま呼び出し元に返すように修正します:

// src/api/axios.ts
import axios from 'axios';
import { isNoRefreshEndpoint } from './config';

const apiClient = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
  timeout: 10000,
});

apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;
    
    if (error.response?.status === 401) {
      // ✅ ログイン関連エンドポイントの401はそのまま返す
      if (isNoRefreshEndpoint(originalRequest.url)) {
        console.log('ログインエラー:', error.response.data.message);
        return Promise.reject(error);
      }
      
      // ✅ その他のエンドポイントではリフレッシュを試行
      if (!originalRequest._retry) {
        originalRequest._retry = true;
        
        try {
          await refreshToken();
          // トークン更新後にリクエストを再実行
          return apiClient.request(originalRequest);
        } catch (refreshError) {
          // リフレッシュ失敗時はログアウト
          logout();
          return Promise.reject(refreshError);
        }
      }
    }
    
    return Promise.reject(error);
  }
);

export default apiClient;

Step 3: Login.tsxでの正しいエラー表示実装

最後に、ログインコンポーネントで401エラーを適切にキャッチして、ユーザーフレンドリーなメッセージを表示します:

// src/components/Login.tsx
import { useState } from 'react';
import apiClient from '../api/axios';

const Login = () => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  const handleLogin = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setIsLoading(true);

    try {
      const response = await apiClient.post('/auth/login', {
        email,
        password,
      });
      
      // ✅ ログイン成功処理
      const { accessToken, refreshToken } = response.data;
      localStorage.setItem('accessToken', accessToken);
      localStorage.setItem('refreshToken', refreshToken);
      
      // リダイレクト処理など
      window.location.href = '/dashboard';
      
    } catch (err: any) {
      // ✅ 401エラーが正常に届くようになった
      if (err.response?.status === 401) {
        setError('メールアドレスまたはパスワードが間違っています');
      } else if (err.response?.status >= 500) {
        setError('サーバーエラーが発生しました。しばらく待ってから再試行してください');
      } else {
        setError('ログインに失敗しました');
      }
    } finally {
      setIsLoading(false);
    }
  };

  return (
    
{/* ✅ エラーメッセージが正しく表示される */} {error && (
{error}
)} setEmail(e.target.value)} placeholder="メールアドレス" required /> setPassword(e.target.value)} placeholder="パスワード" required />
); }; export default Login;

Before/After実装比較

修正前後の動作を比較して、改善点を確認しましょう。

Before:問題のある実装

// ❌ 問題のある実装
axios.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      // すべての401でリフレッシュを試行
      await refreshToken();
      return axios.request(error.config);
    }
    return Promise.reject(error);
  }
);

// 結果:ログインエラーが表示されない
// コンソール:「リフレッシュトークンが見つかりません」

After:修正後の実装

// ✅ 修正後の実装
axios.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      // ログインエンドポイントは除外
      if (isNoRefreshEndpoint(error.config.url)) {
        return Promise.reject(error);
      }
      // その他のエンドポイントでのみリフレッシュ
      await refreshToken();
      return axios.request(error.config);
    }
    return Promise.reject(error);
  }
);

// 結果:「メールアドレスまたはパスワードが間違っています」が表示
// ユーザーが適切にエラーを理解できる

動作確認方法

修正が正しく動作しているかを確認するには、以下の手順でテストしてください:

  1. 間違ったパスワードでログインを試行
  2. エラーメッセージが表示されることを確認
  3. 開発者ツールで「リフレッシュトークンエラー」が出ないことを確認
  4. 正しい認証情報でログインが成功することを確認
  5. ログイン後、トークン期限切れ時にリフレッシュが動作することを確認

予防策:堅牢な認証設計パターン

今回の問題を根本的に防ぐために、以下の設計パターンを採用することをお勧めします。

1. エラーハンドリングの分離設計

認証関連のエラーハンドリングを、用途別に分離して管理します:

// src/api/errorHandlers.ts
export const authErrorHandler = {
  // ログイン系エンドポイント用
  loginError: (error: any) => {
    if (error.response?.status === 401) {
      return '認証情報が正しくありません';
    }
    if (error.response?.status === 422) {
      return 'メールアドレスの形式が正しくありません';
    }
    return 'ログインに失敗しました';
  },
  
  // API呼び出し用
  apiError: async (error: any, originalRequest: any) => {
    if (error.response?.status === 401) {
      try {
        await refreshToken();
        return axios.request(originalRequest);
      } catch (refreshError) {
        logout();
        throw refreshError;
      }
    }
    throw error;
  }
};

2. 詳細なログ出力の実装

デバッグを容易にするため、interceptorで詳細なログ出力を行います:

// src/api/logger.ts
export const apiLogger = {
  logRequest: (config: any) => {
    console.log(`[API Request] ${config.method?.toUpperCase()} ${config.url}`);
  },
  
  logError: (error: any) => {
    const { method, url } = error.config || {};
    const status = error.response?.status;
    const message = error.response?.data?.message || error.message;
    
    console.error(`[API Error] ${method?.toUpperCase()} ${url} - ${status}: ${message}`);
    
    // 401エラーの詳細ログ
    if (status === 401) {
      const isLoginEndpoint = isNoRefreshEndpoint(url);
      console.log(`[Auth Debug] ログインエンドポイント: ${isLoginEndpoint}`);
      console.log(`[Auth Debug] リフレッシュ試行: ${!isLoginEndpoint}`);
    }
  }
};

3. TypeScriptでの型安全性確保

APIレスポンスとエラーの型定義を明確にして、型安全性を確保します:

// src/types/api.ts
export interface ApiError {
  status: number;
  message: string;
  details?: string[];
}

export interface LoginRequest {
  email: string;
  password: string;
}

export interface LoginResponse {
  accessToken: string;
  refreshToken: string;
  user: {
    id: string;
    email: string;
    name: string;
  };
}

export interface ApiResponse {
  data: T;
  status: number;
  message?: string;
}

4. interceptor設計のベストプラクティス

以下のポイントを押さえたinterceptor設計を心がけましょう:

  • エンドポイント別の処理分岐を明確に定義する
  • リトライ制御(_retryフラグ)で無限ループを防ぐ
  • 詳細なログ出力でデバッグを容易にする
  • エラーメッセージの統一でユーザビリティを向上させる
  • テスタビリティを考慮した設計にする

まとめ

axios interceptorでログイン時の401エラーが表示されない問題は、エンドポイント別のエラーハンドリング分岐を実装することで解決できます。

解決のポイント:

  • ログインエンドポイントを除外リストで管理
  • 401エラーの文脈を理解した処理分岐
  • 適切なエラーメッセージのユーザー表示

この実装により、ユーザーは適切なフィードバックを受け取ることができ、開発者もデバッグが容易になります。JWT認証を使用したReactアプリケーションを開発する際は、ぜひこのパターンを参考にしてください。

認証実装をさらに強化する関連記事

今回の解決策を実装したら、関連する認証技術も習得してより堅牢なシステムを構築しましょう:

JWT・認証設計

API・エラーハンドリング

React・デバッグ技法

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次