JWT無限ループが発生する典型的な3つの原因
React+Viteでの開発中、こんなエラーログを見たことありませんか?
❌ 期限切れトークン → 401エラー → リフレッシュ試行 → また401エラー → 無限ループ 🔄
このJWT認証での無限ループは、多くの開発者が遭遇する典型的なバグです。特にReact+Viteの環境では、高速なHMR(Hot Module Replacement)により複数のAPIコールが同時発生しやすく、問題が顕在化しやすくなります。
原因1: 複数API呼び出しでの同時401エラー
最も一般的な原因です。ダッシュボード表示時など、複数のAPIを並行して呼び出した際、すべてで401エラーが発生し、それぞれでリフレッシュトークン処理が実行されてしまいます。
// ❌ 問題のあるコード例
// 複数のAPIを同時に呼び出すと...
useEffect(() => {
Promise.all([
fetchUserProfile(), // 401エラー → リフレッシュ処理1
fetchDashboardData(), // 401エラー → リフレッシュ処理2
fetchNotifications() // 401エラー → リフレッシュ処理3
])
}, [])
// 結果:3つのリフレッシュ処理が並行実行され無限ループ
原因2: リフレッシュトークン自体の有効期限切れ
リフレッシュトークンが既に無効な状態で、リフレッシュ処理が401エラーを返し続ける場合です。
// ❌ 無効なリフレッシュトークンでの無限ループ
async function refreshToken() {
const refreshToken = localStorage.getItem('refreshToken')
// リフレッシュトークンが既に無効なので401エラー
const response = await axios.post('/auth/refresh', { refreshToken })
// → 再度この関数が呼ばれる → 無限ループ
}
原因3: _retryフラグの設定不備
axios interceptorで無限ループを防ぐ_retryフラグが適切に設定されていない場合です。
// ❌ _retryフラグが機能しないコード
axios.interceptors.response.use(
response => response,
async error => {
if (error.response?.status === 401) {
// _retryフラグのチェックが抜けている
await refreshToken()
return axios.request(error.config) // 無限ループの原因
}
}
)
3ステップ解決法:メモ化によるリフレッシュ処理最適化
最も効果的で確実な解決法は、リフレッシュ処理のメモ化です。複数の401エラーが同時に発生しても、実際のリフレッシュ処理は1回だけ実行されます。
ステップ1: メモ化ライブラリの導入と設定
# Viteプロジェクトに必要なパッケージをインストール
npm install axios mem
# または
yarn add axios mem
// src/api/axios.js
import axios from 'axios'
import mem from 'mem'
const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api',
timeout: 60000,
headers: {
'Content-Type': 'application/json'
}
})
export default axiosInstance
ステップ2: リフレッシュ関数のメモ化実装
// src/api/auth.js
import axiosInstance from './axios'
import mem from 'mem'
// ✅ メモ化されたリフレッシュ関数
const refreshTokenFn = async () => {
const refreshToken = localStorage.getItem('refreshToken')
if (!refreshToken) {
// リフレッシュトークンがない場合はログアウト
localStorage.clear()
window.location.href = '/login'
throw new Error('No refresh token available')
}
try {
// 重要:axiosInstanceではなく、素のaxiosを使用
const response = await axios.post(
`${import.meta.env.VITE_API_BASE_URL}/auth/refresh`,
{ refreshToken }
)
const { accessToken, refreshToken: newRefreshToken } = response.data
// 新しいトークンを保存
localStorage.setItem('accessToken', accessToken)
localStorage.setItem('refreshToken', newRefreshToken)
return accessToken
} catch (error) {
// リフレッシュ失敗時はログアウト
localStorage.clear()
window.location.href = '/login'
throw error
}
}
// メモ化設定(10秒間は同じPromiseを返す)
export const memoizedRefreshToken = mem(refreshTokenFn, {
maxAge: 10000 // 10秒
})
ステップ3: Axios Interceptorの設定
// src/api/interceptors.js
import axiosInstance from './axios'
import { memoizedRefreshToken } from './auth'
// リクエストインターセプター:アクセストークンを自動付与
axiosInstance.interceptors.request.use(
config => {
const accessToken = localStorage.getItem('accessToken')
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`
}
return config
},
error => Promise.reject(error)
)
// レスポンスインターセプター:401エラー時の自動リフレッシュ
axiosInstance.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config
// 401エラーかつリフレッシュ処理が未実行の場合
if (
error.response?.status === 401 &&
!originalRequest._retry &&
originalRequest.url !== '/auth/refresh' // リフレッシュAPI自体は除外
) {
originalRequest._retry = true
try {
// ✅ メモ化された関数を呼び出し
const newAccessToken = await memoizedRefreshToken()
// 元のリクエストに新しいトークンを設定
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
// 元のリクエストを再実行
return axiosInstance(originalRequest)
} catch (refreshError) {
// リフレッシュ失敗時は既にログアウト処理済み
return Promise.reject(refreshError)
}
}
return Promise.reject(error)
}
)
export default axiosInstance
Before/After実装コード比較
実際のReact+Viteプロジェクトでの改善例を見てみましょう。
Before: 無限ループが発生するコード
// ❌ 問題のあるコード
// src/hooks/useAuth.js
import { useState, useEffect } from 'react'
import axios from 'axios'
export const useAuth = () => {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
// 毎回新しいaxiosインスタンスを作成(問題1)
const api = axios.create({
baseURL: 'http://localhost:3000/api'
})
// インターセプターが重複登録される(問題2)
api.interceptors.response.use(
response => response,
async error => {
if (error.response?.status === 401) {
// _retryフラグなし(問題3)
const refreshToken = localStorage.getItem('refreshToken')
const response = await api.post('/auth/refresh', { refreshToken })
localStorage.setItem('accessToken', response.data.accessToken)
// 元のリクエストを再実行(無限ループの原因)
return api.request(error.config)
}
}
)
useEffect(() => {
// 複数のAPIを同時呼び出し(問題4)
Promise.all([
api.get('/user/profile'),
api.get('/user/dashboard'),
api.get('/user/notifications')
]).then(responses => {
setUser(responses[0].data)
setLoading(false)
})
}, [])
return { user, loading }
}
After: 無限ループを解決したコード
// ✅ 改善されたコード
// src/hooks/useAuth.js
import { useState, useEffect } from 'react'
import axiosInstance from '../api/interceptors' // 設定済みのインスタンス
export const useAuth = () => {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
const fetchUserData = async () => {
try {
setLoading(true)
setError(null)
// ✅ 順次実行でリスク軽減
const [profileRes, dashboardRes, notificationsRes] = await Promise.all([
axiosInstance.get('/user/profile'),
axiosInstance.get('/user/dashboard'),
axiosInstance.get('/user/notifications')
])
setUser({
profile: profileRes.data,
dashboard: dashboardRes.data,
notifications: notificationsRes.data
})
} catch (error) {
console.error('Failed to fetch user data:', error)
setError(error.message)
} finally {
setLoading(false)
}
}
fetchUserData()
}, [])
return { user, loading, error }
}
アプリケーションのエントリーポイント設定
// src/main.jsx(Viteのエントリーポイント)
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './api/interceptors' // ✅ インターセプターを最初に読み込み
ReactDOM.createRoot(document.getElementById('root')).render(
)
その他の対策法と予防策
対策法1: フラグベースアプローチ
メモ化が使えない場合の代替手段として、LocalStorageでフラグ管理する方法もあります。
// src/api/flagBasedRefresh.js
let isRefreshing = false
let failedQueue = []
const processQueue = (error, token = null) => {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error)
} else {
resolve(token)
}
})
failedQueue = []
}
axiosInstance.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// ✅ 既にリフレッシュ中の場合はキューに追加
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
}).then(token => {
originalRequest.headers.Authorization = `Bearer ${token}`
return axiosInstance(originalRequest)
}).catch(err => {
return Promise.reject(err)
})
}
originalRequest._retry = true
isRefreshing = true
try {
const refreshToken = localStorage.getItem('refreshToken')
const response = await axios.post('/auth/refresh', { refreshToken })
const newAccessToken = response.data.accessToken
localStorage.setItem('accessToken', newAccessToken)
// ✅ キューに溜まっているリクエストを処理
processQueue(null, newAccessToken)
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
return axiosInstance(originalRequest)
} catch (refreshError) {
processQueue(refreshError, null)
localStorage.clear()
window.location.href = '/login'
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
}
)
予防策1: トークン有効期限の事前チェック
// src/utils/tokenUtils.js
export const isTokenExpired = (token) => {
if (!token) return true
try {
const payload = JSON.parse(atob(token.split('.')[1]))
const currentTime = Date.now() / 1000
// 有効期限の30秒前に期限切れと判定
return payload.exp < (currentTime + 30)
} catch {
return true
}
}
// リクエスト前にトークンチェック
axiosInstance.interceptors.request.use(
async config => {
const accessToken = localStorage.getItem('accessToken')
if (isTokenExpired(accessToken)) {
// 事前にリフレッシュ実行
const newToken = await memoizedRefreshToken()
config.headers.Authorization = `Bearer ${newToken}`
} else if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`
}
return config
},
error => Promise.reject(error)
)
予防策2: 開発時のデバッグツール
// src/utils/debugInterceptor.js
if (import.meta.env.DEV) {
let requestCount = 0
axiosInstance.interceptors.request.use(config => {
requestCount++
console.log(`🚀 Request #${requestCount}: ${config.method?.toUpperCase()} ${config.url}`)
if (requestCount > 10) {
console.warn('⚠️ 多数のリクエストが検出されました。無限ループの可能性があります。')
}
return config
})
axiosInstance.interceptors.response.use(
response => {
console.log(`✅ Response: ${response.config.url} - ${response.status}`)
return response
},
error => {
console.log(`❌ Error: ${error.config?.url} - ${error.response?.status}`)
return Promise.reject(error)
}
)
}

よくあるエラーパターンと即解決法
エラー1: “Maximum call stack size exceeded”
// ❌ 原因:axios instanceの循環参照
const api = axios.create()
api.interceptors.response.use(
res => res,
async error => {
if (error.response?.status === 401) {
await api.post('/refresh') // ❌ 同じインスタンスを使用
}
}
)
// ✅ 解決法:素のaxiosを使用
api.interceptors.response.use(
res => res,
async error => {
if (error.response?.status === 401) {
await axios.post('/refresh') // ✅ 素のaxiosを使用
}
}
)
エラー2: “Network Error” の連続発生
// ✅ 解決法:リフレッシュ回数の制限
let refreshAttempts = 0
const MAX_REFRESH_ATTEMPTS = 3
axiosInstance.interceptors.response.use(
response => response,
async error => {
if (error.response?.status === 401 && refreshAttempts < MAX_REFRESH_ATTEMPTS) {
refreshAttempts++
try {
await memoizedRefreshToken()
return axiosInstance(error.config)
} catch (refreshError) {
if (refreshAttempts >= MAX_REFRESH_ATTEMPTS) {
refreshAttempts = 0
// 強制ログアウト
localStorage.clear()
window.location.href = '/login'
}
return Promise.reject(refreshError)
}
}
refreshAttempts = 0 // 成功時はリセット
return Promise.reject(error)
}
)
エラー3: Vite環境変数の読み込みエラー
// ❌ 間違った環境変数の使用
const API_URL = process.env.REACT_APP_API_URL // ❌ Viteでは動作しない
// ✅ 正しいViteの環境変数
const API_URL = import.meta.env.VITE_API_URL
// .env.local ファイルの設定例
// VITE_API_URL=http://localhost:3000/api
// VITE_APP_NAME=My React App
まとめ:JWT無限ループの確実な対策法
React+ViteでのJWT無限ループ問題は、以下の3ステップで確実に解決できます:
- メモ化の導入:
memライブラリでリフレッシュ処理を一意化 - 適切なインターセプター設定:
_retryフラグと循環参照の回避 - 予防的なトークン管理: 有効期限の事前チェックとデバッグツール
特にメモ化によるアプローチは、複数API呼び出しが頻発するモダンなSPAにおいて最も効果的な解決策です。実装も比較的シンプルで、既存のコードへの影響を最小限に抑えられます。
この記事の実装パターンをコピペして使用すれば、JWT認証の無限ループ問題は確実に解決できます。開発時のデバッグツールも活用して、今後の問題を未然に防ぎましょう。
React+ViteとNext.jsの技術選択で迷っている方は、上記の記事も参考にして、プロジェクトに最適なフレームワークを選択してください。
