JavaScriptアプリケーションにおけるリフレッシュトークンを使用した安全な「ログイン状態を維持する」機能の実装
Wenhao Wang
Dev Intern · Leapcell

はじめに
ペースの速いWebアプリケーションの世界では、ユーザーの利便性が最優先されます。 この利便性を大幅に向上させる機能の1つが「ログイン状態を維持する」機能であり、ユーザーは認証情報を繰り返し入力することなく、ブラウザセッションを超えてログイン状態を維持できます。 一見すると単純ですが、安全で永続的な「ログイン状態を維持する」機能の実装は、特にユーザーデータ保護と認可の永続性に関して、興味深い課題を提示します。 従来の アプローチ は、セキュリティと永続性のバランスを取る上でしばしば不十分です。 この記事では、JavaScriptアプリケーション向けの堅牢なソリューション、つまりリフレッシュトークンを長期かつ安全な「ログイン状態を維持する」戦略として活用することについて掘り下げます。 基本的な概念を探り、実装の詳細を議論し、効果的で安全なシステムを構築する方法をデモンストレーションします。
コアコンセプトの説明
実装に進む前に、議論の背骨を形成するいくつかの重要な用語を定義しましょう。
- アクセストークン: 保護されたリソースにアクセスするために使用される認証情報です。 アクセストークンは通常、短命(数分から1時間)であり、安全なAPIへのすべてのリクエストに含まれます。 その短い有効期間は、トークン盗難のリスクを最小限に抑えます。
- リフレッシュトークン: 現在のアクセストークンが期限切れになった後に、新しいアクセストークンを取得するために使用される長命の認証情報です。 アクセストークンとは異なり、リフレッシュトークンはすべてのAPIリクエストに含まれるわけではありません。 より安全に保存され、通常はアクセストークンを更新する必要がある場合にのみ使用されます。
- JWT(JSON Web Token): 2者間で転送されるクレームを表現する、コンパクトなURLセーフな手段です。 JWTは、ユーザーID、ロール、有効期限などの情報を含むアクセストークンとしてよく使用され、整合性を確保するためにデジタル署名されています。
- セッション vs. 永続性: セッションは通常、2つ以上の通信デバイスまたはプログラム間の、一時的な対話型情報交換を指します。 この文脈での永続性とは、ユーザーのログイン状態がブラウザの終了や長期間の非アクティビティを生き延びる能力を意味します。
- HTTP-only Cookie: クライアントサイドJavaScriptからアクセスできない特別な種類のCookieです。 攻撃者がCookieの値だけを簡単に読み取ることができないため、クロスサイトスクリプティング(XSS)攻撃に対して耐性があります。 これは、リフレッシュトークンなどの機密トークンを保存するための重要なセキュリティ対策です。
- CSRF(クロスサイトリクエストフォージェリ)トークン: サーバーによって生成され、クライアントに送信される、秘密で一意で予測不可能な値です。 クライアントは、通常はヘッダーまたはフォームフィールドで、後続のリクエストにこのトークンを含める必要があります。 サーバーはこのトークンを検証し、他のドメインから開始された不正なリクエストを防ぎます。
リフレッシュトークンを使用した「ログイン状態を維持する」機能の実装
コアアイデアは、即時のAPIリクエストには短命のアクセストークンを使用し、必要に応じて新しいアクセストークンを再発行するために安全に保存された長命のリフレッシュトークンを使用することです。 このアプローチは、セキュリティとユーザーの利便性の間の強力なバランスを提供します。
フロー
-
ユーザーログイン:
- ユーザーは資格情報(ユーザー名/パスワード)を提供します。
- バックエンドはユーザーを認証します。
- 成功した場合、バックエンドはアクセストークンとリフレッシュトークンを発行します。
- アクセストークンは通常、レスポンスボディまたは非HTTP-only Cookieで送信されます。
- リフレッシュトークンは、サーバーからHTTP-only、セキュアCookieとして設定されます。 これにより、JavaScriptからのアクセスが防止され、XSSのリスクが軽減されます。
- 「ログイン状態を維持する」チェックボックスがオンになっている場合、リフレッシュトークンの有効期限ははるかに長く設定されます(例:30日から数ヶ月)。 そうでない場合、標準のセッションCookieまたはより短い期間になる可能性があります。
-
後続のAPIリクエスト:
- JavaScriptクライアントは、すべての保護されたAPIリクエストでアクセストークン(例:
Authorization: Bearer <token>
ヘッダー)を含めます。
- JavaScriptクライアントは、すべての保護されたAPIリクエストでアクセストークン(例:
-
アクセストークンの有効期限切れ:
- アクセストークンが期限切れになると、古いトークンを含むAPIリクエストは失敗します(例:401 Unauthorizedステータスコード)。
- クライアントサイドJavaScriptは、この401エラーを検出します。
-
トークンの更新:
- 期限切れのアクセストークンを検出すると、クライアントはサーバー上の専用の「トークン更新」エンドポイントにリクエストを送信します。
- このリクエストは、HTTP-onlyリフレッシュトークンCookieをサーバーに自動的に送信します。
- サーバーはリフレッシュトークンを検証します。
- 有効性と有効期限を確認します。
- 失効していないことを確認します。
- (オプションですが推奨)関連するCSRFトークンを検証します。
- 有効な場合、サーバーは新しいアクセストークンと、場合によっては新しいリフレッシュトークン(セキュリティ強化のためのトークンローテーション)を発行します。
- 新しいアクセストークンがクライアントに返されます。 新しいリフレッシュトークンが発行された場合、HTTP-only Cookieで古いものを上書きします。
-
元のリクエストの再試行:
- 新しいアクセストークンを使用して、クライアントは失敗した元のAPIリクエストを再試行します。
コード例(概念、フロントエンド&バックエンド)
簡略化されたJavaScript(フロントエンド)とNode.js(バックエンド)の例で示しましょう。
フロントエンド(JavaScript - axios
を使用したリクエスト)
// アクセストークンの簡単なストレージ(実際のアプリでは、より堅牢な状態管理を使用してください) let accessToken = null; // アクセストークンを保存する関数 const setAccessToken = (token) => { accessToken = token; // 実際のアプリでは、ページリフレッシュ時の即時アクセス用に localStorage にも保存する場合があります // localStorage.setItem('accessToken', token); }; // アクセストークンを取得する関数 const getAccessToken = () => { // return accessToken || localStorage.getItem('accessToken'); return accessToken; }; // トークン処理のインターセプターを備えた Axios インスタンス const api = axios.create({ baseURL: '/api', }); api.interceptors.request.use( (config) => { const token = getAccessToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, (error) => Promise.reject(error) ); api.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; // エラーが401で、まだリトライしていない場合 if (error.response.status === 401 && !originalRequest._retry) { originalRequest._retry = true; // リトライ済みとしてマーク try { // リフレッシュトークン(HTTP-only Cookie内)を使用して新しいアクセストークンをリクエスト const refreshResponse = await axios.post('/api/auth/refresh-token'); setAccessToken(refreshResponse.data.accessToken); // 元のリクエストのヘッダーを新しいトークンで更新 originalRequest.headers.Authorization = `Bearer ${refreshResponse.data.accessToken}`; // 元のリクエストをリトライ return api(originalRequest); } catch (refreshError) { // リフレッシュトークンが失敗した、おそらく期限切れまたは無効。 // ユーザーをログアウトさせるか、ログインにリダイレクトします。 console.error("Refresh token failed:", refreshError); // 保存されているトークンをクリアしてログインにリダイレクト setAccessToken(null); // localStorage.removeItem('accessToken'); window.location.href = '/login'; return Promise.reject(refreshError); } } return Promise.reject(error); } ); // --- ユーザーログイン例 --- async function loginUser(username, password, rememberMe) { try { const response = await axios.post('/api/auth/login', { username, password, rememberMe, // 「ログイン状態を維持する」設定を送信 }); setAccessToken(response.data.accessToken); // バックエンドがリフレッシュトークンをHTTP-only Cookieとして設定します console.log("Logged in successfully!"); // ダッシュボードまたはホームページにリダイレクト } catch (error) { console.error("Login failed:", error.response?.data?.message || error.message); } } // 使用例: // loginUser('user@example.com', 'password123', true); // api.get('/user/profile').then(res => console.log(res.data));
バックエンド(Node.js with Express & JWT)
const express = require('express'); const jwt = require('jsonwebtoken'); const cookieParser = require('cookie-parser'); // HTTP-only Cookie の解析用 const csrf = require('csurf'); // CSRF 保護用 const app = express(); app.use(express.json()); app.use(cookieParser()); const JWT_SECRET = 'your_jwt_secret_key'; // 強力な、環境変数を使用してください const REFRESH_TOKEN_SECRET = 'your_refresh_token_secret_key'; // 強力な、環境変数を使用してください // CSRF 保護の設定、CSRFトークン自体には Cookie を使用 const csrfProtection = csrf({ cookie: true }); app.use(csrfProtection); // グローバルまたは特定のルートに適用 // ダミーユーザーデータ const users = [{ id: 1, username: 'user@example.com', password: 'password123' }]; // ト ークン生成ヘルパー const generateTokens = (user, rememberMe) => { const accessToken = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '15m' }); // 短命 // 「rememberMe」に基づいたリフレッシュトークンの有効期限 const refreshTokenExpiry = rememberMe ? '30d' : '1h'; // 30日 vs 1時間 const refreshToken = jwt.sign({ userId: user.id }, REFRESH_TOKEN_SECRET, { expiresIn: refreshTokenExpiry }); return { accessToken, refreshToken }; }; // アクセストークン検証ミドルウェア const authenticateAccessToken = (req, res, next) => { const authHeader = req.headers.authorization; if (!authHeader) return res.status(401).json({ message: 'No access token provided' }); const token = authHeader.split(' ')[1]; if (!token) return res.status(401).json({ message: 'Token format is Bearer <token>' }); jwt.verify(token, JWT_SECRET, (err, user) => { if (err) return res.status(401).json({ message: 'Invalid or expired access token' }); req.user = user; next(); }); }; // --- AUTH ENDPOINTS --- // Login app.post('/api/auth/login', (req, res) => { const { username, password, rememberMe } = req.body; const user = users.find(u => u.username === username && u.password === password); if (!user) { return res.status(401).json({ message: 'Invalid credentials' }); } const { accessToken, refreshToken } = generateTokens(user, rememberMe); // リフレッシュトークンを HTTP-only セキュア Cookie として設定 res.cookie('refreshToken', refreshToken, { httpOnly: true, // クライアントサイドJSからアクセス不可 secure: process.env.NODE_ENV === 'production', // 本番環境では HTTPS 接続のみで送信 sameSite: 'Lax', // CSRF 保護 - クロスサイトリクエストで送信しないようにする maxAge: (rememberMe ? 30 * 24 * 60 * 60 * 1000 : 60 * 60 * 1000), // 30日または1時間 }); // CSRFトークンをクライアントがアクセスできるCookieまたはヘッダーに設定 const csrfToken = req.csrfToken(); res.cookie('XSRF-TOKEN', csrfToken); // クライアントサイドアクセス用 res.json({ accessToken, csrfToken }); // CSRFトークンもレスポンスボディで送信 }); // Token Refresh app.post('/api/auth/refresh-token', csrfProtection, (req, res) => { // CSRF保護を適用 const refreshToken = req.cookies.refreshToken; if (!refreshToken) { return res.status(401).json({ message: 'No refresh token provided' }); } jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, (err, user) => { if (err) { // 期限切れ/無効なリフレッシュトークンをクリア res.clearCookie('refreshToken'); return res.status(403).json({ message: 'Invalid or expired refresh token' }); } // トークンローテーション: 新しいアクセストークンとともに新しいリフレッシュトークンを発行 const { accessToken, refreshToken: newRefreshToken } = generateTokens(user, true); // リフレッシュトークンを使用している場合は「rememberMe」が true であると仮定 res.cookie('refreshToken', newRefreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'Lax', maxAge: 30 * 24 * 60 * 60 * 1000, // 30日 }); const csrfToken = req.csrfToken(); res.cookie('XSRF-TOKEN', csrfToken); res.json({ accessToken, csrfToken }); }); }); // Logout (DBに保存されている場合、リフレッシュトークンを無効化) app.post('/api/auth/logout', (req, res) => { // 実際のアプリでは、データベースからリフレッシュトークンを無効化したい場合があります res.clearCookie('refreshToken'); res.clearCookie('XSRF-TOKEN'); // CSRFトークンもクリア res.status(200).json({ message: 'Logged out successfully' }); }); // ---PROTECTED ROUTES --- // 保護されたルートの例 app.get('/api/user/profile', authenticateAccessToken, (req, res) => { res.json({ message: `Welcome, user ${req.user.userId}! Your profile data here.` }); }); app.listen(3000, () => console.log('Server running on port 3000'));
セキュリティ上の考慮事項
- **リフレッシュトークン用のHTTP-only Cookie:**これは重要です。 これにより、JavaScriptがセキュアでない場合でもリフレッシュトークンにアクセスできなくなり、攻撃者が悪意のあるスクリプトを注入してCookieを盗むXSS攻撃に対して、リフレッシュトークンが脆弱でなくなります。
- **Cookieの
Secure
フラグ:**本番環境でsecure
フラグをtrue
に設定することを保証します。 これにより、CookieはHTTPS接続でのみ送信されるようになり、盗聴から保護されます。 - **Cookieの
SameSite
フラグ:**リフレッシュトークンCookieのSameSite
属性(例:Lax
またはStrict
)を設定すると、ブラウザにクロスサイトリクエストでCookieを送信しないように指示することで、CSRF攻撃の緩和に役立ちます。 - リフレッシュエンドポイントのCSRF保護:
SameSite
を使用しても、リフレッシュトークン(/refresh-token
)エンドポイントに追加のCSRF保護を実装することは良い習慣です。 これは通常、通常の(HTTP-onlyでない)CookieまたはlocalStorage
に保存されたCSRFトークンをクライアントがリフレッシュリクエストとともに送信し、サーバーが検証することによって行われます。 - **トークン失効:**リフレッシュトークンを無効化するメカニズム(例:データベースに保存してログアウト時またはアカウント侵害時に無効とマークする)を実装します。 これは、セキュリティインシデント対応に不可欠です。
- **トークンローテーション:**各成功したトークン更新時に新しいリフレッシュトークンを発行し、(古いものを無効にする)ことはベストプラクティスです。 攻撃者がリフレッシュトークンを盗んだ場合、次の正規のリフレッシュ操作の後、無用になります。
- **レート制限:**レート制限を実装することで、ログインおよびトークン更新エンドポイントをブルートフォース攻撃から保護します。
- **環境変数:**機密性の高い秘密(JWT秘密など)をコードに直接ハードコーディングしないでください。 環境変数を使用します。
アプリケーションシナリオ
このリフレッシュトークン戦略は、以下に最適です。
- シングルページアプリケーション(SPA): React、Vue、Angular アプリケーションで、完全なページリロードなしで永続的なログインが期待されます。
- モバイルアプリケーション(ハイブリッド/PWA): モバイルユーザーにシームレスなログインエクスペリエンスを提供します。
- 強力なセキュリティを維持しながら、長命の認証状態を必要とするあらゆるクライアントサイドアプリケーション。
結論
安全な「ログイン状態を維持する」機能の実装は、単純なタスクではありませんが、リフレッシュトークンを慎重に活用し、堅牢なセキュリティプラクティスに従うことで、開発者はユーザーエクスペリエンスとアプリケーションセキュリティの両方を大幅に向上させることができます。 短命のアクセストークンと長命のHTTP-onlyリフレッシュトークンを組み合わせ、トークンローテーションやCSRF保護などの対策で強化されたアプローチは、JavaScriptアプリケーションにおける永続的な認証のための、回復力があり業界標準のソリューションを提供します。 これは、ユーザーの利便性への欲求と、機密ユーザーデータを保護する義務との間で効果的なバランスを取ります。