Skip to content
Published on

React SPA認証実践ガイド — Cookie vs ストレージ、Axiosインターセプター、XSS/CSRF防御完全攻略

Authors

SSO Cookie/JWT認証シリーズ · Django編 · 現在:React編 · Next.js編

概要 — SPA認証の特殊性

従来のMulti-Page Application(MPA)では、サーバーがセッションを管理し、リクエストごとにHTMLをレンダリングしながら認証状態を自然に維持していた。サーバーサイドテンプレートエンジンがログイン状態を確認し、未認証ユーザーはログインページにリダイレクトするだけでよかった。

Reactのような Single-Page Application(SPA)は根本的に異なる。ブラウザ上のJavaScriptがすべての画面遷移を処理し、サーバーとはAPI呼び出しでのみ通信する。このアーキテクチャにおける認証は、以下のような固有の課題を抱えている。

  • 状態の二重性:サーバーのセッション/トークンの有効性とクライアントのUI状態が分離され、同期の問題が発生する
  • ページリロード:SPAでリロードするとJavaScriptメモリが初期化されるため、認証状態をどこかに永続的に保存する必要がある
  • トークン保存場所:ストレージの選択がセキュリティモデルを決定する。localStorage、sessionStorage、Cookie、メモリのどこに保存するかがXSS/CSRF防御戦略全体を左右する
  • CORS制約:フロントエンドとバックエンドが異なるドメインにデプロイされる場合、Cookieと認証ヘッダーの動作が複雑になる

この記事では、React SPAで認証を実装する際に知っておくべきすべてを扱う。ストレージ比較、認証状態管理パターン、Axiosインターセプター、XSS/CSRF防御、Protected Route、Silent Refreshまで、実践TypeScriptコードとともに完全攻略しよう。

ブラウザストレージ別アクセス可能性表

トークンをどこに保存するかは、SPA認証における最も重要な設計判断だ。各ストレージの特性を正確に理解する必要がある。

ストレージJSアクセスサーバー自動送信XSS脆弱CSRF脆弱
HttpOnly Cookie不可あり不可あり
Non-HttpOnly Cookie可能ありありあり
localStorage可能なしありなし
sessionStorage可能なしありなし
Memory(JS変数/state)可能なし一部なし

各方式のセキュリティトレードオフ

HttpOnly CookieはJavaScriptからアクセスできないため、XSS攻撃に最も強い。ブラウザが毎リクエストで自動的にCookieを送信するため、フロントエンドでトークンを直接扱う必要がない。ただし、自動送信の特性からCSRF攻撃に脆弱であるため、CSRFトークンやSameSite属性で防御する必要がある。

localStorage/sessionStorageはJavaScriptから自由にアクセスでき、実装が簡単だ。サーバーに自動送信されないためCSRFには安全だが、XSS攻撃で窃取される可能性がある。sessionStorageはタブを閉じると消えるが、XSSに露出している間は同様に危険だ。

**メモリ(JS変数/state)**はリロード時に消えるため永続性がないが、XSS攻撃で直接アクセスするのが最も困難だ(不可能ではない)。CSRFにも安全だ。リロード問題はSilent Refreshで解決できる。

推奨組み合わせ:HttpOnly Cookie(Access TokenまたはRefresh Token)+ CSRFトークン + SameSite=Lax/Strict

認証状態管理パターン

React Context + useReducer

最も基本的でありながら効果的な方法だ。外部ライブラリなしにReact組み込み機能のみで実装する。

// types/auth.ts
export interface User {
  id: string
  email: string
  name: string
  roles: string[]
}

export interface AuthState {
  user: User | null
  isAuthenticated: boolean
  isLoading: boolean
}

export type AuthAction =
  | { type: 'AUTH_START' }
  | { type: 'AUTH_SUCCESS'; payload: User }
  | { type: 'AUTH_FAILURE' }
  | { type: 'LOGOUT' }
// context/AuthContext.tsx
import React, { createContext, useReducer, useContext, useEffect, useCallback } from 'react';
import type { AuthState, AuthAction, User } from '../types/auth';
import { apiClient } from '../lib/apiClient';

const initialState: AuthState = {
  user: null,
  isAuthenticated: false,
  isLoading: true, // 初期ローディング状態
};

function authReducer(state: AuthState, action: AuthAction): AuthState {
  switch (action.type) {
    case 'AUTH_START':
      return { ...state, isLoading: true };
    case 'AUTH_SUCCESS':
      return { user: action.payload, isAuthenticated: true, isLoading: false };
    case 'AUTH_FAILURE':
      return { user: null, isAuthenticated: false, isLoading: false };
    case 'LOGOUT':
      return { user: null, isAuthenticated: false, isLoading: false };
    default:
      return state;
  }
}

interface AuthContextType extends AuthState {
  login: (email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
  refreshUser: () => Promise<void>;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(authReducer, initialState);

  // アプリ初期ロード時に認証状態を確認
  const refreshUser = useCallback(async () => {
    dispatch({ type: 'AUTH_START' });
    try {
      const { data } = await apiClient.get<User>('/api/auth/me');
      dispatch({ type: 'AUTH_SUCCESS', payload: data });
    } catch {
      dispatch({ type: 'AUTH_FAILURE' });
    }
  }, []);

  useEffect(() => {
    refreshUser();
  }, [refreshUser]);

  const login = async (email: string, password: string) => {
    dispatch({ type: 'AUTH_START' });
    try {
      await apiClient.post('/api/auth/login', { email, password });
      // ログイン成功後、ユーザー情報を取得
      const { data } = await apiClient.get<User>('/api/auth/me');
      dispatch({ type: 'AUTH_SUCCESS', payload: data });
    } catch (error) {
      dispatch({ type: 'AUTH_FAILURE' });
      throw error; // コンポーネントでエラーハンドリング
    }
  };

  const logout = async () => {
    try {
      await apiClient.post('/api/auth/logout');
    } finally {
      dispatch({ type: 'LOGOUT' });
    }
  };

  return (
    <AuthContext.Provider value={{ ...state, login, logout, refreshUser }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth(): AuthContextType {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

Zustandパターン(簡潔な代替手段)

Context + useReducerの代わりにZustandを使用すると、ボイラープレートを大幅に削減できる。

// store/authStore.ts
import { create } from 'zustand'
import type { User } from '../types/auth'
import { apiClient } from '../lib/apiClient'

interface AuthStore {
  user: User | null
  isAuthenticated: boolean
  isLoading: boolean
  login: (email: string, password: string) => Promise<void>
  logout: () => Promise<void>
  refreshUser: () => Promise<void>
}

export const useAuthStore = create<AuthStore>((set) => ({
  user: null,
  isAuthenticated: false,
  isLoading: true,

  login: async (email, password) => {
    set({ isLoading: true })
    await apiClient.post('/api/auth/login', { email, password })
    const { data } = await apiClient.get<User>('/api/auth/me')
    set({ user: data, isAuthenticated: true, isLoading: false })
  },

  logout: async () => {
    await apiClient.post('/api/auth/logout')
    set({ user: null, isAuthenticated: false, isLoading: false })
  },

  refreshUser: async () => {
    try {
      const { data } = await apiClient.get<User>('/api/auth/me')
      set({ user: data, isAuthenticated: true, isLoading: false })
    } catch {
      set({ user: null, isAuthenticated: false, isLoading: false })
    }
  },
}))

トークン保存戦略別実装

方法1:HttpOnly Cookie(推奨)

サーバーがSet-Cookieヘッダーでトークンを設定し、フロントエンドはトークンに直接アクセスしない。最も安全な方法だ。

// サーバーレスポンスヘッダー(Django/Expressなど)
// Set-Cookie: access_token=eyJ...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900

// フロントエンドではwithCredentialsを設定するだけ
const apiClient = axios.create({
  baseURL: process.env.REACT_APP_API_URL,
  withCredentials: true, // Cookieをリクエストに含める
})

// ログイン
const login = async (email: string, password: string) => {
  // サーバーがSet-Cookieでトークンを設定してくれる
  await apiClient.post('/api/auth/login', { email, password })
  // トークンを直接扱わない!サーバーからユーザー情報のみ取得
  const { data } = await apiClient.get('/api/auth/me')
  return data
}

この方式では、フロントエンドはトークン値を一切知らない。ユーザー情報が必要な場合は/api/auth/meのようなエンドポイントでサーバーに問い合わせる。

方法2:In-Memory(セキュリティ優先)

Access TokenをJavaScript変数にのみ保持する。XSSでストレージを探してもトークンが見つからない。

// lib/tokenManager.ts
let accessToken: string | null = null

export const tokenManager = {
  getToken: (): string | null => accessToken,

  setToken: (token: string): void => {
    accessToken = token
  },

  clearToken: (): void => {
    accessToken = null
  },
}
// リロード時にRefresh Token(HttpOnly Cookie)でAccess Tokenを再発行
const silentRefresh = async (): Promise<string | null> => {
  try {
    const { data } = await axios.post(
      '/api/auth/refresh',
      {},
      { withCredentials: true } // Refresh TokenはHttpOnly Cookie
    )
    tokenManager.setToken(data.accessToken)
    return data.accessToken
  } catch {
    tokenManager.clearToken()
    return null
  }
}

このパターンでは、Access Tokenはメモリに、Refresh TokenはHttpOnly Cookieに保存する。二つの利点を組み合わせ、Access TokenはXSSから安全であり、Refresh TokenはCSRF+SameSiteで保護される。

方法3:localStorage(簡単だが要注意)

最も実装が簡単だが、XSSに脆弱であることを必ず認識する必要がある。

// lib/tokenStorage.ts
const ACCESS_TOKEN_KEY = 'access_token'

export const tokenStorage = {
  getToken: (): string | null => localStorage.getItem(ACCESS_TOKEN_KEY),

  setToken: (token: string): void => {
    localStorage.setItem(ACCESS_TOKEN_KEY, token)
  },

  clearToken: (): void => {
    localStorage.removeItem(ACCESS_TOKEN_KEY)
  },
}

localStorageが許容される場合:

  • 内部管理ツールなど信頼できる環境
  • トークンの有効期間が非常に短く(5分以下)、Refresh TokenはHttpOnly Cookieに保存する場合
  • CSPが厳格に設定されており、外部スクリプトの挿入が事実上不可能な場合

localStorageを避けるべき場合:

  • ユーザー入力をHTMLとしてレンダリングするサービス(掲示板、コメント、Wikiなど)
  • サードパーティスクリプトが多く挿入されるページ
  • 高いセキュリティが要求される金融・医療サービス

Axiosインターセプター設定

認証リクエストのコアであるAxiosインターセプターを設定する。リクエストでトークンを自動挿入し、レスポンスで401を検知してトークンを更新する。

// lib/apiClient.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { tokenManager } from './tokenManager'

const apiClient = axios.create({
  baseURL: process.env.REACT_APP_API_URL,
  timeout: 10000,
  withCredentials: true, // Cookie方式の場合必須
})

// --- リクエストインターセプター ---
apiClient.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    // In-MemoryまたはlocalStorage方式の場合Authorizationヘッダーを追加
    const token = tokenManager.getToken()
    if (token && config.headers) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => Promise.reject(error)
)

// --- レスポンスインターセプター:401検知 → トークンリフレッシュ → リトライ ---
let isRefreshing = false
let failedQueue: Array<{
  resolve: (token: string | null) => void
  reject: (error: unknown) => void
}> = []

const processQueue = (error: unknown, token: string | null = null) => {
  failedQueue.forEach(({ resolve, reject }) => {
    if (error) {
      reject(error)
    } else {
      resolve(token)
    }
  })
  failedQueue = []
}

apiClient.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as InternalAxiosRequestConfig & {
      _retry?: boolean
    }

    // 401でない、またはすでにリトライ済みの場合はエラー伝播
    if (error.response?.status !== 401 || originalRequest._retry) {
      return Promise.reject(error)
    }

    // すでにリフレッシュ中の場合はキューに追加
    if (isRefreshing) {
      return new Promise((resolve, reject) => {
        failedQueue.push({
          resolve: (token) => {
            if (token && originalRequest.headers) {
              originalRequest.headers.Authorization = `Bearer ${token}`
            }
            resolve(apiClient(originalRequest))
          },
          reject,
        })
      })
    }

    originalRequest._retry = true
    isRefreshing = true

    try {
      const { data } = await axios.post(
        `${process.env.REACT_APP_API_URL}/api/auth/refresh`,
        {},
        { withCredentials: true }
      )

      const newToken = data.accessToken
      tokenManager.setToken(newToken)
      processQueue(null, newToken)

      if (originalRequest.headers) {
        originalRequest.headers.Authorization = `Bearer ${newToken}`
      }
      return apiClient(originalRequest)
    } catch (refreshError) {
      processQueue(refreshError, null)
      tokenManager.clearToken()
      // ログインページにリダイレクト
      window.location.href = '/login'
      return Promise.reject(refreshError)
    } finally {
      isRefreshing = false
    }
  }
)

export { apiClient }

fetch APIを使用する場合

Axiosの代わりにfetchを使用する場合はcredentials: 'include'を設定する。

const response = await fetch(`${API_URL}/api/auth/me`, {
  method: 'GET',
  credentials: 'include', // Cookieを含める
  headers: {
    'Content-Type': 'application/json',
    // In-Memory/localStorage方式の場合
    ...(token ? { Authorization: `Bearer ${token}` } : {}),
  },
})

ログイン/ログアウトフロー

ログインコンポーネント

// components/LoginForm.tsx
import { useState, FormEvent } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'

export function LoginForm() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState<string | null>(null)
  const [isSubmitting, setIsSubmitting] = useState(false)

  const { login } = useAuth()
  const navigate = useNavigate()
  const location = useLocation()

  // ログイン前にアクセスしようとしたページがあればそこにリダイレクト
  const from = (location.state as { from?: string })?.from || '/dashboard'

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault()
    setError(null)
    setIsSubmitting(true)

    try {
      await login(email, password)
      navigate(from, { replace: true })
    } catch (err) {
      setError('メールアドレスまたはパスワードが正しくありません。')
    } finally {
      setIsSubmitting(false)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">メールアドレス</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
          autoComplete="email"
        />
      </div>
      <div>
        <label htmlFor="password">パスワード</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
          autoComplete="current-password"
        />
      </div>
      {error && (
        <p role="alert" style={{ color: 'red' }}>
          {error}
        </p>
      )}
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'ログイン中...' : 'ログイン'}
      </button>
    </form>
  )
}

ログアウト処理

// components/LogoutButton.tsx
import { useAuth } from '../context/AuthContext'
import { useNavigate } from 'react-router-dom'

export function LogoutButton() {
  const { logout } = useAuth()
  const navigate = useNavigate()

  const handleLogout = async () => {
    await logout()
    // ログアウト後、サーバーでHttpOnly Cookieの削除を処理
    // クライアント状態はAuthContextで初期化済み
    navigate('/login', { replace: true })
  }

  return <button onClick={handleLogout}>ログアウト</button>
}

JWTクレームパース(フロントエンド)

Non-HttpOnlyトークンやサーバーから別途受け取ったトークンのクレームを読み取り、UIに表示できる。ただし、フロントエンドでパースしたクレームは表示用途のみであり、認証・認可の判断に使用してはならない

// lib/jwtUtils.ts
import { jwtDecode } from 'jwt-decode'

interface JwtPayload {
  sub: string
  email: string
  name: string
  roles: string[]
  exp: number
  iat: number
}

// jwt-decodeライブラリを使用
export function parseToken(token: string): JwtPayload | null {
  try {
    return jwtDecode<JwtPayload>(token)
  } catch {
    return null
  }
}

// ライブラリなしで直接パース
export function parseTokenManual(token: string): JwtPayload | null {
  try {
    const base64Url = token.split('.')[1]
    const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
    const jsonPayload = decodeURIComponent(
      atob(base64)
        .split('')
        .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
        .join('')
    )
    return JSON.parse(jsonPayload)
  } catch {
    return null
  }
}

// トークン有効期限確認
export function isTokenExpired(token: string, bufferSeconds = 30): boolean {
  const payload = parseToken(token)
  if (!payload) return true
  // 期限のbufferSeconds前から期限切れとみなす
  return payload.exp * 1000 < Date.now() + bufferSeconds * 1000
}

// ユーザー表示用情報抽出
export function extractDisplayInfo(token: string) {
  const payload = parseToken(token)
  if (!payload) return null
  return {
    name: payload.name,
    email: payload.email,
    roles: payload.roles,
  }
}

フロントエンドクレーム処理原則

  1. 表示用(Display):名前、メールアドレス、プロフィール画像URLなどをUIに表示するためにパースするのは問題ない
  2. 認可用(Authorization):ロール、権限などはサーバーで検証すべきだ。フロントエンドでroles.includes('admin')によるUI分岐はUX向上目的であり、セキュリティではない
  3. 期限確認:フロントエンドで有効期限を確認し、事前に更新をトリガーするのは良いパターンだ

Protected Routeの実装

認証が必要なページを保護するルートコンポーネントを実装する。

// components/RequireAuth.tsx
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'

interface RequireAuthProps {
  children: React.ReactNode
  requiredRoles?: string[]
}

export function RequireAuth({ children, requiredRoles }: RequireAuthProps) {
  const { isAuthenticated, isLoading, user } = useAuth()
  const location = useLocation()

  // 初期ローディング中はローディングUIを表示
  if (isLoading) {
    return (
      <div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
        <span>認証確認中...</span>
      </div>
    )
  }

  // 未認証 → ログインページにリダイレクト、現在のパスを記憶
  if (!isAuthenticated) {
    return <Navigate to="/login" state={{ from: location.pathname }} replace />
  }

  // ロールベースのアクセス制御
  if (requiredRoles && user) {
    const hasRequiredRole = requiredRoles.some((role) => user.roles.includes(role))
    if (!hasRequiredRole) {
      return <Navigate to="/unauthorized" replace />
    }
  }

  return <>{children}</>
}
// App.tsx — ルート設定例
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { AuthProvider } from './context/AuthContext'
import { RequireAuth } from './components/RequireAuth'
import { LoginForm } from './components/LoginForm'
import { Dashboard } from './pages/Dashboard'
import { AdminPanel } from './pages/AdminPanel'

function App() {
  return (
    <BrowserRouter>
      <AuthProvider>
        <Routes>
          <Route path="/login" element={<LoginForm />} />
          <Route
            path="/dashboard"
            element={
              <RequireAuth>
                <Dashboard />
              </RequireAuth>
            }
          />
          <Route
            path="/admin"
            element={
              <RequireAuth requiredRoles={['admin']}>
                <AdminPanel />
              </RequireAuth>
            }
          />
        </Routes>
      </AuthProvider>
    </BrowserRouter>
  )
}

XSS防御

Cross-Site Scripting(XSS)攻撃はSPA認証における最大の脅威だ。攻撃者がページに悪意のあるスクリプトを挿入すると、トークンの窃取、Cookieへのアクセス、キーストロークの傍受などが可能になる。

Reactのデフォルト防御

ReactはJSXで変数をレンダリングする際、自動的にHTMLエスケープ処理を行う。

// 安全 — Reactが自動エスケープ
const userInput = '<script>alert("XSS")</script>'
return <div>{userInput}</div>
// レンダリング:&lt;script&gt;alert("XSS")&lt;/script&gt;

dangerouslySetInnerHTMLの危険性

// 危険!! — 絶対にユーザー入力を直接入れてはいけない
return <div dangerouslySetInnerHTML={{ __html: userInput }} />

// どうしても使用する必要がある場合はDOMPurifyでサニタイズ
import DOMPurify from 'dompurify'

const sanitized = DOMPurify.sanitize(userInput, {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'],
  ALLOWED_ATTR: [],
})
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />

Content Security Policy(CSP)

<!-- public/index.html またはサーバーレスポンスヘッダー -->
<meta
  http-equiv="Content-Security-Policy"
  content="
    default-src 'self';
    script-src 'self';
    style-src 'self' 'unsafe-inline';
    img-src 'self' data: https:;
    connect-src 'self' https://api.example.com;
  "
/>

CSPを設定することで、外部ドメインのスクリプトロード、インラインスクリプトの実行をブロックし、XSS攻撃の影響を大幅に軽減できる。

CSRF防御

Cookieを使用すると、ブラウザがリクエストに自動的にCookieを含めるため、CSRF攻撃に脆弱になる。

SameSite Cookie設定

Set-Cookie: access_token=eyJ...; HttpOnly; Secure; SameSite=Lax; Path=/
  • SameSite=Strict:外部サイトからのすべてのリクエストでCookieを送信しない(リンククリックを含む)
  • SameSite=Lax:GETリクエスト(リンククリック)は許可、POST/PUT/DELETEはブロック
  • SameSite=None; Secure:すべてのクロスオリジンリクエストでCookieを送信(クロスドメインSSO時)

Double Submit Cookieパターン

// サーバーがCSRFトークンをNon-HttpOnly Cookieで送信
// Set-Cookie: csrf_token=abc123; Path=/; SameSite=Lax(HttpOnlyではない!)

// フロントエンド:CookieからCSRFトークンを読み取ってヘッダーとして送信
function getCsrfToken(): string {
  const match = document.cookie.match(/csrf_token=([^;]+)/)
  return match ? match[1] : ''
}

// Axiosインターセプターで自動追加
apiClient.interceptors.request.use((config) => {
  const csrfToken = getCsrfToken()
  if (csrfToken && config.headers) {
    config.headers['X-CSRF-Token'] = csrfToken
  }
  return config
})

Djangoとの連携

DjangoはcsrftokenCookieをデフォルトで提供する。Reactでこれを読み取り、X-CSRFTokenヘッダーとして送信する。

// Django CSRF連携
apiClient.interceptors.request.use((config) => {
  const csrfToken = document.cookie
    .split('; ')
    .find((row) => row.startsWith('csrftoken='))
    ?.split('=')[1]

  if (csrfToken && config.headers) {
    config.headers['X-CSRFToken'] = csrfToken
  }
  return config
})

CORS設定の注意事項

フロントエンド(例:localhost:3000)とバックエンド(例:localhost:8000)が異なるドメイン/ポートで実行される場合、CORSポリシーが適用される。

credentials: 'include'時の制約

// フロントエンド
axios.defaults.withCredentials = true
// または
fetch(url, { credentials: 'include' })

この設定を使用する場合、サーバー側で必ず以下を遵守する必要がある:

# Django例(django-cors-headers)
CORS_ALLOWED_ORIGINS = [
    "http://localhost:3000",  # ワイルドカード'*'は使用不可!
]
CORS_ALLOW_CREDENTIALS = True
  • Access-Control-Allow-Origin: *credentials: includeと共に使用できない
  • 必ず具体的なドメインを指定する必要がある
  • Preflight(OPTIONS)リクエストも同じヘッダーを返す必要がある

開発環境のプロキシ設定

開発段階でCORS問題を回避する最も簡単な方法はプロキシだ。

// package.json(Create React App)
{
  "proxy": "http://localhost:8000"
}
// vite.config.ts(Vite)
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8000',
        changeOrigin: true,
      },
    },
  },
})

プロキシを使用すると、フロントエンドとバックエンドが同じoriginで動作するため、CORSとCookieの問題が解決される。ただし、本番環境ではNginxなどのリバースプロキシで同じ効果を実現する必要がある。

Silent Refresh / Token Rotation

Access Tokenの有効期限が切れた際に、ユーザー体験を損なわずに新しいトークンを発行する方法だ。

Refresh Token Rotationパターン

Refresh Tokenを使用するたびに新しいRefresh Tokenを発行し、以前のトークンを無効化する。トークンが窃取されても一度しか使用できないため、被害を最小化できる。

// lib/silentRefresh.ts
import { apiClient } from './apiClient'
import { tokenManager } from './tokenManager'

let refreshTimer: ReturnType<typeof setTimeout> | null = null

export function scheduleRefresh(expiresInMs: number) {
  // 有効期限の1分前に更新
  const refreshTime = expiresInMs - 60 * 1000

  if (refreshTimer) {
    clearTimeout(refreshTimer)
  }

  if (refreshTime <= 0) {
    // すでに期限切れ間近 → 即座に更新
    performRefresh()
    return
  }

  refreshTimer = setTimeout(performRefresh, refreshTime)
}

async function performRefresh() {
  try {
    const { data } = await apiClient.post('/api/auth/refresh')
    tokenManager.setToken(data.accessToken)
    // 新しいトークンの有効期限で次の更新をスケジュール
    scheduleRefresh(data.expiresIn * 1000)
  } catch {
    tokenManager.clearToken()
    window.location.href = '/login'
  }
}

export function cancelRefresh() {
  if (refreshTimer) {
    clearTimeout(refreshTimer)
    refreshTimer = null
  }
}

setInterval vs リクエスト時更新

戦略メリットデメリット
setInterval / setTimeout期限前に事前更新、UXの途切れなし非アクティブタブでも実行、不要なネットワークリクエスト
リクエスト時確認必要な時のみ更新、効率的更新中に若干の遅延が発生する可能性

実務では、Axiosインターセプターでリクエスト時に期限を確認し、タイマーで事前更新するハイブリッド戦略を推奨する。上記のAxiosインターセプターコードがリクエスト時更新を、scheduleRefreshがタイマーベース更新を担当する。

チェックリスト

React SPA認証実装時に必ず確認すべき項目だ。

  • トークン保存場所の決定:HttpOnly Cookie(推奨)またはIn-Memory + HttpOnly Refresh Cookie
  • withCredentials / credentials設定:Cookie方式使用時にすべてのAPIリクエストに設定
  • Axiosインターセプター実装:401検知 → トークン更新 → リトライ、レースコンディション防止
  • AuthProvider実装:アプリ初期ロード時の認証状態確認(/api/auth/me
  • Protected Route実装:未認証 → リダイレクト、ロールベースのアクセス制御
  • CSRFトークン処理:Cookie使用時のDouble Submit CookieまたはSameSite設定
  • CSP設定:script-src、connect-srcなどの厳格なポリシー適用
  • dangerouslySetInnerHTML監査:使用箇所ごとのDOMPurify適用確認
  • CORS設定確認:credentials + 具体的ドメイン、preflightレスポンス確認
  • Silent Refresh実装:トークン期限前の自動更新、Refresh Token Rotation
  • ログアウト処理:サーバー + クライアント両側の状態初期化、Cookie削除API
  • エラーハンドリング:ネットワークエラー、トークン更新失敗時のユーザー案内

よくあるバグと誤解

1. 「JWTをlocalStorageに入れたら絶対ダメ」 — 本当?

この主張は誇張だ。localStorageのJWTはXSSに脆弱だが、XSSが発生した場合、HttpOnly Cookie方式でも攻撃者はユーザーのセッションでAPIを呼び出せる(トークンを直接窃取しなくても)。核心はXSSを防止することであり、保存場所だけでセキュリティが完成するわけではない。とはいえ、HttpOnly Cookieが多層防御でもう一段階の保護を提供するのは事実であり、推奨される。

2. トークン期限チェックをフロントエンドでのみ行う

フロントエンドでexpクレームを確認して期限切れトークンを送信しないのはUX最適化だ。しかし、サーバーは必ず独立してトークンの有効性を検証すべきだ。フロントエンドの時計がずれている可能性があり、トークンがサーバー側でrevokeされている可能性もある。

3. withCredentialsを設定したのにCookieが送信されない

  • CORSレスポンスにAccess-Control-Allow-Credentials: trueがない場合
  • Access-Control-Allow-Origin: *を使用している場合(credentialsとワイルドカードは共存不可)
  • SameSite=Strictで異なるドメインからリクエストしている場合
  • SecureフラグだがHTTP(非HTTPS)でリクエストしている場合
  • 開発環境でlocalhostと127.0.0.1を混用している場合

4. XSSとCSRFの混同

XSSCSRF
攻撃方法悪意のあるスクリプトの挿入認証済みユーザーのブラウザ経由で偽造リクエスト
防御入力サニタイズ、CSP、HttpOnly CookieCSRFトークン、SameSite Cookie
トークン保存localStorage脆弱、HttpOnly Cookie安全Cookie全般脆弱、localStorage安全

これらは異なる攻撃ベクターであり、両方を同時に防御する必要がある。

5. Refresh TokenをAccess Tokenのように毎リクエストで送信

Refresh Tokenはトークン更新エンドポイントにのみ送信すべきだ。すべてのAPIリクエストでRefresh Tokenを送信すると攻撃対象面が広がる。CookieのPath属性を/api/auth/refreshに制限することで強制できる。

6. ログアウト時にクライアント状態のみクリア

クライアントでトークンを削除しても、サーバーではまだ有効だ(JWTはstateless)。サーバーにログアウトAPIを呼び出してRefresh Tokenを無効化(revoke)してはじめて完全なログアウトとなる。

7. 初期ロード時のisLoading処理漏れ

AuthProviderが/api/auth/meを呼び出して認証状態を確認している間、isLoadingはtrueであるべきだ。これを漏らすと、未認証状態で一瞬レンダリングされた後、認証後にちらつく現象が発生する。Protected Routeで必ずisLoading状態を処理しよう。

参考資料