SPA認証の設計
この章では、シングルページアプリケーション(SPA)における認証の設計と実装について学びます。
この章で学ぶこと
- SPAにおける認証の課題と対策
- JWT認証(アクセストークン + リフレッシュトークン)の仕組み
- セキュアなトークン保存(メモリ + HttpOnly Cookie)
- Axiosインターセプターによる自動トークン管理
この章では認証の設計と実装パターンを学びます。フロントエンドのコードは03章のプロジェクトをベースに実装できます。バックエンドは次章(28章)で構築します。
SPAにおける認証の課題
従来のサーバーサイドレンダリングとは異なり、SPAには特有の認証課題があります。
従来のWebアプリケーション vs SPA
| 観点 | 従来のWebアプリ | SPA |
|---|---|---|
| セッション管理 | サーバー側 | クライアント側 |
| ページ遷移 | サーバーで認証チェック | クライアントで認証チェック |
| トークン保存 | Cookie(自動送信) | 明示的な管理が必要 |
| CSRF対策 | 必須 | ヘッダー送信分は不要(Cookie併用時は必要) |
| XSS対策 | 重要 | 非常に重要 |
認証方式の比較
1. セッション認証
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Client │ ───> │ Server │ ───> │ Session │
│ (Cookie) │ <─── │ (Set-Cookie)│ <─── │ Store │
└─────────────┘ └─────────────┘ └─────────────┘
メリット: サーバー側で無効化可能、実装がシンプル デメリット: サーバーに状態を持つ、スケールが難しい。
2. JWT認証(アクセストークン + リフレッシュトークン)
┌─────────────┐ ┌─────────────┐
│ Client │ ───> │ Server │
│ │ │ │
│ Access Token│ │ Verify JWT │
│ (Memory/ │ <─── │ │
│ Storage) │ │ Issue JWT │
│ │ │ │
│ Refresh │ │ Verify │
│ Token │ ───> │ Refresh │
│ (HttpOnly) │ <─── │ Token │
└─────────────┘ └─────────────┘
メリット: ステートレス、スケーラブル、マイクロサービス対応 デメリット: 実装が複雑、トークン無効化が難しい。
推奨: JWT + HttpOnly Cookie
本書では、セキュリティと実用性のバランスを取った以下の方式を採用します。
| トークン | 保存場所 | 有効期限 | 用途 |
|---|---|---|---|
| アクセストークン | メモリ(状態管理) | 15分 | API認証 |
| リフレッシュトークン | HttpOnly Cookie | 7日 | トークン更新 |
トークンの保存場所とセキュリティ
localStorage / sessionStorage
localStorageやsessionStorageにアクセストークンを保存することは、セキュリティ上のリスクがあります。XSS攻撃でJavaScriptからアクセスされる可能性があるため、本番環境では推奨されません。
// ❌ 非推奨: XSS脆弱性
localStorage.setItem('accessToken', token)
リスク: XSS攻撃でJavaScriptからアクセス可能。
HttpOnly Cookie
// ✅ 推奨: JavaScriptからアクセス不可
// サーバー側で設定
res.cookie('refreshToken', token, {
httpOnly: true,
secure: true, // HTTPS必須
sameSite: 'strict', // CSRF対策
maxAge: 7 * 24 * 60 * 60 * 1000 // 7日
})
メモリ(React State / Zustand)
// ✅ 推奨: アクセストークンはメモリに保持
// リロード時は再取得が必要
const useAuthStore = create<AuthState>((set) => ({
accessToken: null,
setAccessToken: (token) => set({ accessToken: token })
}))
アクセストークンをメモリに保持しても、XSS 攻撃を完全に防げるわけではありません。XSS でスクリプト実行を許した時点で、攻撃者は被害者のブラウザ上で fetch を直接呼び出せます。Cookie に HttpOnly が付いていれば Cookie 自体は読めませんが、攻撃者が「ブラウザの名前で API を叩く」ことは止められません。つまりメモリ保存は Cookie の盗難を防ぐ効果はあるものの、XSS 被害を無害化するわけではないという位置づけです。根本対策として、Content Security Policy(CSP)や信頼できない HTML をレンダリングしない設計が必須になります。
OAuth 2.1 / OIDC / PKCE の基礎
本書では自前で JWT を発行・検証する構成を扱いますが、外部の認証プロバイダ(Auth0、Okta、Google、GitHub など)に認証を委譲する場合は OAuth 2.0 / 2.1 および OpenID Connect(OIDC) に準拠するのが一般的です。最低限の用語を整理しておきます。
| 用語 | 役割 |
|---|---|
| OAuth 2.0 | 「認可(Authorization)」のための標準。特定のリソースへのアクセス権を委譲する仕組み |
| OAuth 2.1 | OAuth 2.0 のベストプラクティスを取り込んだ後継仕様。実質的に SPA では PKCE が必須になった |
| OpenID Connect (OIDC) | OAuth 2.0 の上に「認証(Authentication)」を乗せた規格。ID Token(JWT)でユーザー情報を取得できる |
| Authorization Code Flow | ユーザーを認証プロバイダへリダイレクトし、戻ってきた認可コードをトークンに交換するフロー |
| PKCE(Proof Key for Code Exchange) | 認可コード横取り攻撃を防ぐための追加検証。SPA では必須 |
SPA はクライアントシークレットを安全に保管できないため、現在の業界標準は「Authorization Code Flow with PKCE」です。Implicit Flow(アクセストークンを直接 URL フラグメントで受け取る古い方式)は廃止されており、使ってはいけません。
OAuth / OIDC ライブラリ(oidc-client-ts、@auth0/auth0-react など)が PKCE の細部を処理してくれるため、通常はライブラリに任せます。本書の自前実装は学習用として読み、実運用では認証プロバイダの利用を第一候補にしてください。
認証フローの設計
ログインフロー
1. User enters credentials
│
▼
2. POST /api/auth/login
│
▼
3. Server validates credentials
│
▼
4. Server returns:
- Access Token (response body)
- Refresh Token (HttpOnly Cookie)
│
▼
5. Client stores Access Token in memory
トークンリフレッシュフロー
1. API request with expired Access Token
│
▼
2. Server returns 401 Unauthorized
│
▼
3. Client calls POST /api/auth/refresh
(Refresh Token auto-sent via Cookie)
│
▼
4. Server validates Refresh Token
│
▼
5. Server returns new Access Token
│
▼
6. Client retries original request
実装: 認証ストア
Zustandで認証状態を管理
// stores/authStore.ts
import { create } from 'zustand'
type User = {
id: string
email: string
name: string
}
type AuthState = {
user: User | null
accessToken: string | null
isAuthenticated: boolean
isLoading: boolean
setAuth: (user: User, accessToken: string) => void
clearAuth: () => void
setLoading: (loading: boolean) => void
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
accessToken: null,
isAuthenticated: false,
isLoading: true,
setAuth: (user, accessToken) =>
set({
user,
accessToken,
isAuthenticated: true,
isLoading: false
}),
clearAuth: () =>
set({
user: null,
accessToken: null,
isAuthenticated: false,
isLoading: false
}),
setLoading: (isLoading) => set({ isLoading })
}))
実装: API クライアント
HTTPクライアントとしてaxiosを使います。まずインストールしましょう。
npm install axios
Axiosインターセプターでトークン管理
// lib/api.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '../stores/authStore'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
withCredentials: true // Cookie送信を有効化
})
// リクエストインターセプター: アクセストークンを付与
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const { accessToken } = useAuthStore.getState()
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`
}
return config
})
// レスポンスインターセプター: 401時にトークンリフレッシュ
let isRefreshing = false
let failedQueue: Array<{
resolve: (token: string) => void
reject: (error: unknown) => void
}> = []
const processQueue = (error: unknown, token: string | null = null) => {
failedQueue.forEach((promise) => {
if (error) {
promise.reject(error)
} else {
promise.resolve(token!)
}
})
failedQueue = []
}
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean
}
// 401エラーかつリトライ前(/auth/refresh 自身の401は除外)
if (
error.response?.status === 401 &&
!originalRequest._retry &&
!originalRequest.url?.includes('/auth/refresh')
) {
if (isRefreshing) {
// リフレッシュ中は待機
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`
return api(originalRequest)
})
}
originalRequest._retry = true
isRefreshing = true
try {
const { data } = await axios.post(
`${import.meta.env.VITE_API_URL}/auth/refresh`,
{},
{ withCredentials: true }
)
const { accessToken, user } = data
useAuthStore.getState().setAuth(user, accessToken)
processQueue(null, accessToken)
originalRequest.headers.Authorization = `Bearer ${accessToken}`
return api(originalRequest)
} catch (refreshError) {
processQueue(refreshError, null)
useAuthStore.getState().clearAuth()
// 保護ページからのAPIリクエスト失敗時のみここに到達する
// (公開ページの初期認証チェックは AuthProvider の catch に任せる)
window.location.href = '/login'
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
}
)
export default api
実装: 認証フック
ログイン・ログアウト
// hooks/useAuth.ts
import { useEffect } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router'
import api from '../lib/api'
import { useAuthStore } from '../stores/authStore'
type LoginCredentials = {
email: string
password: string
}
type RegisterData = {
name: string
email: string
password: string
}
export function useLogin() {
const { setAuth } = useAuthStore()
const navigate = useNavigate()
return useMutation({
mutationFn: async (credentials: LoginCredentials) => {
const { data } = await api.post('/auth/login', credentials)
return data
},
onSuccess: (data) => {
setAuth(data.user, data.accessToken)
navigate('/dashboard')
}
})
}
export function useRegister() {
const { setAuth } = useAuthStore()
const navigate = useNavigate()
return useMutation({
mutationFn: async (userData: RegisterData) => {
const { data } = await api.post('/auth/register', userData)
return data
},
onSuccess: (data) => {
setAuth(data.user, data.accessToken)
navigate('/dashboard')
}
})
}
export function useLogout() {
const { clearAuth } = useAuthStore()
const navigate = useNavigate()
return useMutation({
mutationFn: async () => {
await api.post('/auth/logout')
},
onSuccess: () => {
clearAuth()
navigate('/login')
}
})
}
// 初期認証状態の確認(リロード時)
export function useAuthCheck() {
const { setAuth, clearAuth } = useAuthStore()
const query = useQuery({
queryKey: ['auth', 'me'],
queryFn: async () => {
const { data } = await api.get('/auth/me')
return data
},
retry: false,
staleTime: Infinity,
gcTime: Infinity
})
// v5 では useQuery の onSuccess / onError が廃止されたため、副作用は useEffect で処理する
useEffect(() => {
if (query.data) {
// /auth/me の応答に accessToken は含まれないため、既存のトークンを維持する
// (undefined で上書きすると、マウントごとに余分なリフレッシュ往復が発生する)
const { accessToken } = useAuthStore.getState()
if (accessToken) {
setAuth(query.data.user, accessToken)
}
}
}, [query.data, setAuth])
useEffect(() => {
if (query.isError) {
clearAuth()
}
}, [query.isError, clearAuth])
return query
}
実装: 保護されたルート
PrivateRouteコンポーネント
// components/PrivateRoute.tsx
import { Navigate, useLocation } from 'react-router'
import { useAuthStore } from '../stores/authStore'
type PrivateRouteProps = {
children: React.ReactNode
}
export function PrivateRoute({ children }: PrivateRouteProps) {
const { isAuthenticated, isLoading } = useAuthStore()
const location = useLocation()
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
)
}
if (!isAuthenticated) {
// ログイン後に元のページに戻れるようにする
return <Navigate to="/login" state={{ from: location }} replace />
}
return <>{children}</>
}
ルーティング設定
// App.tsx
import { Routes, Route } from 'react-router'
import { PrivateRoute } from './components/PrivateRoute'
import { AuthProvider } from './components/AuthProvider'
import { LoginPage } from './pages/LoginPage'
import { RegisterPage } from './pages/RegisterPage'
import { DashboardPage } from './pages/DashboardPage'
import { ProjectsPage } from './pages/ProjectsPage'
function App() {
return (
<AuthProvider>
<Routes>
{/* 公開ルート */}
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
{/* 保護されたルート */}
<Route
path="/dashboard"
element={
<PrivateRoute>
<DashboardPage />
</PrivateRoute>
}
/>
<Route
path="/projects/*"
element={
<PrivateRoute>
<ProjectsPage />
</PrivateRoute>
}
/>
</Routes>
</AuthProvider>
)
}
AuthProvider(初期認証チェック)
// components/AuthProvider.tsx
import { useEffect, ReactNode } from 'react'
import { useAuthStore } from '../stores/authStore'
import api from '../lib/api'
type AuthProviderProps = {
children: ReactNode
}
export function AuthProvider({ children }: AuthProviderProps) {
const { setAuth, clearAuth } = useAuthStore()
useEffect(() => {
const checkAuth = async () => {
try {
// リフレッシュトークンで認証状態を復元
const { data } = await api.post('/auth/refresh')
setAuth(data.user, data.accessToken)
} catch {
clearAuth()
}
}
checkAuth()
}, [setAuth, clearAuth])
return <>{children}</>
}
実装: ログインフォーム
// pages/LoginPage.tsx
import { useState } from 'react'
import { Link, useLocation, Navigate } from 'react-router'
import { useLogin } from '../hooks/useAuth'
import { useAuthStore } from '../stores/authStore'
export function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const { mutate: login, isPending, error } = useLogin()
const { isAuthenticated } = useAuthStore()
const location = useLocation()
// すでにログイン済みならリダイレクト
if (isAuthenticated) {
const from = location.state?.from?.pathname || '/dashboard'
return <Navigate to={from} replace />
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
login({ email, password })
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow">
<h2 className="text-3xl font-bold text-center">ログイン</h2>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-3 bg-red-100 text-red-700 rounded">
メールアドレスまたはパスワードが正しくありません
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium">
メールアドレス
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
パスワード
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<button
type="submit"
disabled={isPending}
className="w-full py-2 px-4 bg-blue-500 text-white rounded-md
hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? 'ログイン中...' : 'ログイン'}
</button>
</form>
<p className="text-center text-sm text-gray-600">
アカウントをお持ちでない方は
<Link to="/register" className="text-blue-500 hover:underline ml-1">
新規登録
</Link>
</p>
</div>
</div>
)
}
セキュリティのベストプラクティス
1. HTTPS必須
// 本番環境ではHTTPSを強制
if (import.meta.env.PROD && location.protocol !== 'https:') {
location.replace(`https:${location.href.substring(location.protocol.length)}`)
}
2. CSRFトークン(Cookie使用時)
// APIクライアントでCSRFトークンを送信
api.interceptors.request.use((config) => {
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('XSRF-TOKEN='))
?.split('=')[1]
if (csrfToken) {
config.headers['X-XSRF-TOKEN'] = csrfToken
}
return config
})
3. XSS対策
// ユーザー入力のサニタイズ
import DOMPurify from 'dompurify'
function sanitizeInput(input: string): string {
return DOMPurify.sanitize(input)
}
// dangerouslySetInnerHTMLは使用を避ける
// 使用する場合は必ずサニタイズ
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(content) }} />
4. パスワード要件
// バリデーション例
const passwordSchema = z
.string()
.min(8, 'パスワードは8文字以上')
.regex(/[A-Z]/, '大文字を1文字以上含める')
.regex(/[a-z]/, '小文字を1文字以上含める')
.regex(/[0-9]/, '数字を1文字以上含める')
試してみよう
以下の課題に挑戦して、SPA認証の理解を深めましょう。
-
認証ストアを実装する: Zustandを使って、
user、accessToken、isAuthenticated、isLoadingを管理する認証ストアを作成してください。setAuthとclearAuthアクションも実装しましょう。 -
PrivateRouteを実装する: 認証されていないユーザーを
/loginにリダイレクトするPrivateRouteコンポーネントを作成してください。ローディング中は適切なUIを表示しましょう。 -
ログインフォームを実装する: メールアドレスとパスワードのフォームを作成し、TanStack Queryの
useMutationでログイン処理を実装してください。エラー時のメッセージ表示も追加しましょう。
ヒント
create<AuthState>((set) => ({ ... }))でストアを作成します。setAuthはuserとaccessTokenを受け取り、isAuthenticated: trueに設定します。useAuthStoreからisAuthenticatedとisLoadingを取得し、条件に応じて<Navigate to="/login" />またはchildrenを返します。useMutationのonSuccessでsetAuthを呼び、onErrorでエラーメッセージを設定します。isPendingでボタンの無効化も実装しましょう。
回答と解説
課題1: 認証ストアを実装する
// stores/authStore.ts
import { create } from 'zustand'
type User = {
id: string
email: string
name: string
}
type AuthState = {
user: User | null
accessToken: string | null
isAuthenticated: boolean
isLoading: boolean
setAuth: (user: User, accessToken: string) => void
clearAuth: () => void
setLoading: (loading: boolean) => void
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
accessToken: null,
isAuthenticated: false,
isLoading: true,
setAuth: (user, accessToken) =>
set({
user,
accessToken,
isAuthenticated: true,
isLoading: false
}),
clearAuth: () =>
set({
user: null,
accessToken: null,
isAuthenticated: false,
isLoading: false
}),
setLoading: (isLoading) => set({ isLoading })
}))
Zustandではcreate関数に型パラメータを渡すことで型安全なストアを作成できます。setAuthはログイン成功時に呼び出し、clearAuthはログアウトや認証エラー時に呼び出します。isLoadingは初期認証チェック中にtrueになります。
課題2: PrivateRouteを実装する
// components/PrivateRoute.tsx
import { Navigate, useLocation } from 'react-router'
import { useAuthStore } from '../stores/authStore'
type PrivateRouteProps = {
children: React.ReactNode
}
export function PrivateRoute({ children }: PrivateRouteProps) {
const { isAuthenticated, isLoading } = useAuthStore()
const location = useLocation()
// 認証チェック中はローディング表示
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500" />
</div>
)
}
// 未認証の場合はログインページへリダイレクト
if (!isAuthenticated) {
// ログイン後に元のページへ戻れるように現在のパスを保存
return <Navigate to="/login" state={{ from: location }} replace />
}
// 認証済みの場合は子コンポーネントを表示
return <>{children}</>
}
// App.tsx での使用例
import { Routes, Route } from 'react-router'
import { PrivateRoute } from './components/PrivateRoute'
function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/dashboard"
element={
<PrivateRoute>
<DashboardPage />
</PrivateRoute>
}
/>
</Routes>
)
}
isLoading中にリダイレクトしないことが重要です。ページリロード時に認証チェックが完了する前にリダイレクトすると、認証済みユーザーもログインページに飛ばされてしまいます。location.stateに元のパスを保存することで、ログイン後に元のページに戻れます。
課題3: ログインフォームを実装する
// pages/LoginPage.tsx
import { useState } from 'react'
import { Link, useLocation, Navigate, useNavigate } from 'react-router'
import { useMutation } from '@tanstack/react-query'
import { useAuthStore } from '../stores/authStore'
import api from '../lib/api'
type LoginCredentials = {
email: string
password: string
}
export function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const { isAuthenticated, setAuth } = useAuthStore()
const location = useLocation()
const navigate = useNavigate()
const loginMutation = useMutation({
mutationFn: async (credentials: LoginCredentials) => {
const { data } = await api.post('/auth/login', credentials)
return data
},
onSuccess: (data) => {
setAuth(data.user, data.accessToken)
const from = location.state?.from?.pathname || '/dashboard'
navigate(from, { replace: true })
}
})
// すでにログイン済みならダッシュボードへ
if (isAuthenticated) {
return <Navigate to="/dashboard" replace />
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
loginMutation.mutate({ email, password })
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow">
<h2 className="text-3xl font-bold text-center">ログイン</h2>
<form onSubmit={handleSubmit} className="space-y-6">
{loginMutation.isError && (
<div className="p-3 bg-red-100 text-red-700 rounded">
メールアドレスまたはパスワードが正しくありません
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium">
メールアドレス
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border rounded-md"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
パスワード
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border rounded-md"
/>
</div>
<button
type="submit"
disabled={loginMutation.isPending}
className="w-full py-2 px-4 bg-blue-500 text-white rounded-md
hover:bg-blue-600 disabled:opacity-50"
>
{loginMutation.isPending ? 'ログイン中...' : 'ログイン'}
</button>
</form>
<p className="text-center text-sm text-gray-600">
アカウントをお持ちでない方は
<Link to="/register" className="text-blue-500 hover:underline ml-1">
新規登録
</Link>
</p>
</div>
</div>
)
}
useMutationを使うと、ローディング状態(isPending)、エラー状態(isError)、成功時のコールバック(onSuccess)を簡潔に扱えます。onSuccessでsetAuthを呼び出して認証状態を更新し、保存していた元のパスにリダイレクトします。
まとめ
- SPAでは認証トークンの保存場所に注意(XSS対策)
- アクセストークンはメモリ、リフレッシュトークンはHttpOnly Cookie
- Axiosインターセプターで自動トークン更新
- 保護されたルートで認証チェック
- HTTPS、CSRF対策、XSS対策を忘れずに
次の章では、NestJSとPrismaを使ったバックエンドAPIの構築を学びます。