🔐

セッションベース認証 vs トークンベース認証

に公開

はじめに

Webアプリケーションの認証方式を設計する際、
セッションベース認証(Cookie)とトークン認証(JWTなど)のどちらを採用すべきか迷うことはないでしょうか。

この選択は、プロダクトのアーキテクチャとセキュリティ方針によって決まります。

例えば次のような条件です。

  • SPAや複数オリジン構成か
  • モバイルアプリからアクセスするか
  • API中心の設計か
  • セキュリティ対策(CSRF / XSS への対応)

こうした条件によって、適した認証方式は変わります。

本記事では「セッションベース認証(Cookie)」と「トークン認証(JWT / Bearer Token)」の違いに焦点を当てて解説します。

本記事では、シーケンス図と比較表を用いて次の点を整理します。

  • セッションベース認証(Cookie)の実装フロー
  • トークン認証(JWT / Bearer Token)の実装フロー
  • 各方式が適しているアーキテクチャ
  • CSRF / XSS などのセキュリティリスクと対策
  • アーキテクチャに応じた認証方式の選択基準

この記事を読むことで、認証方式を実装レベルで理解し、適切な方式を判断できるようになります。


要点まとめ

セッションベース認証 vs トークンベース認証

セッションベース認証 トークンベース認証
認証情報の保存 セッションストア(サーバー側) トークン自体に情報を含め、署名で改ざん検知(主にJWT)
ブラウザ側の保管 Cookieに自動で送受信(HttpOnly推奨) localStorage / インメモリ / Cookie
スケーラビリティ セッション共有が必要で難しい サーバーがステートレス(容易)
セキュリティリスク CSRF(トークン検証で対応) XSS(保存場所次第)
トークン漏洩時の対応 サーバーでセッション即時無効化可能 リフレッシュトークンで段階的対応
実装の複雑さ 比較的シンプル 保存場所や有効期限の組み合わせで複雑化

使い分けの判断基準

Step 1: システムアーキテクチャで判断

  • 同一オリジン(サーバーとフロント同じドメイン) → セッションベース認証
  • 複数オリジン / モバイルアプリ / マイクロサービス → トークンベース認証

Step 2: トークンベース認証を選んだ場合、保存場所を選ぶ

  • クッキーに保存 → 実装シンプル、XSS対策容易(推奨)
  • インメモリに保存 → セキュリティ重視、実装複雑
  • localStorage → 非推奨(XSS脆弱性高い)

Step 3: 有効期限とリフレッシュ機構を決める

  • 短期有効期限 + リフレッシュトークン → セキュリティとUX のバランス型
  • 長期有効期限 → シンプルさ重視(セキュリティリスク増)

認証選択の判断軸

認証方式の選択は「セキュリティ」だけで決まるわけではありません。クライアント環境、トークン失効の対応、スケーラビリティなど複数の要素が影響します。プロジェクトの条件を整理した上で、最適な方式を選ぶ必要があります。

主要な判断軸

1. クライアント・サーバーの配置(複数オリジン対応の必要性)

クッキーは同一サイト (SameSite) の制約を受けます。同じドメイン内であれば別サブドメイン間でもCookie共有が可能です(例:Domain=.example.comapp.example.comapi.example.com で共有可能)。ただし、完全に異なるドメイン間では扱いが難しくなります。

シナリオ 説明 対応方式
モノリシック サーバーサイドレンダリング(Rails、Djangoなど) セッションベース認証が自然
同一オリジン SPA React/Next.js と API が同じオリジン(フロントが example.com/app、API が example.com/api セッションベース認証が容易(CORS設定で対応)
異なるオリジン SPA フロント(app.example.com)と API(api.example.com Cookie + CORS で可能だが実装が面倒。トークンベース認証が採用されることが多い
モバイルアプリ ネイティブアプリ、React Native クッキー機構がない → トークンベース認証
複数の API マイクロサービス、複数のバックエンド トークンベース認証でスケーラブルに

2. セキュリティレベルの要求

各方式には異なるセキュリティリスクがあります。

セッションベース認証の主要リスク:

  • CSRF(クロスサイトリクエストフォージェリ)- ブラウザが自動的にクッキーを送信するため、攻撃者が不正なリクエストを実行可能

トークンベース認証の主要リスク:

  • XSS(クロスサイトスクリプティング)- JavaScript からトークンが窃取される可能性(保存場所による)
    • localStorage に保存:XSS に脆弱
    • インメモリ保存:XSS発生時も盗まれるのは実行中のスクリプトだけ。リロード・再起動で消えるため、localStorageより低リスク
    • クッキー保存:XSS リスク低い

セキュリティインシデント時、認証情報を即座に無効化できるかどうかも重要です。

方式 即時無効化 方法
セッションベース ✓ 可能 サーバー側のセッションストアから削除
トークンベース(短い有効期限) ✗ 不可 有効期限内のトークンは使用可能。漏洩時のリスク期間が長い
トークンベース(ブラックリスト) ✓ 可能 失効させたトークンをブラックリストに登録
トークンベース(リフレッシュトークン) △ 部分的 リフレッシュトークンを失効させられるが、アクセストークンは有効期限内に使用可能

4. スケーラビリティの要求

サーバーのスケールアウト時、セッション管理の方式が影響します。

方式 スケーラビリティ 特徴
セッションベース認証 ○ 中程度 セッション情報はサーバーに保存。Redis 等のセッションストア(またはクッキーに署名を含める)を使うことでスケール可能
トークンベース ✓ 良い サーバーがステートレス。トークンはクライアント側で保持し、サーバーは検証するだけなのでスケールが容易

セッションベース認証

サーバー側でセッション情報を管理し、セッションIDをクッキーに保存して認証を行う方式です。

セッションベース認証のログイン時の流れ

ユーザーがemailとpasswordを送信し、ブラウザにセッションIDが設定されるまでの流れを示します。

詳細
  1. POST /login (email, password)

    • ユーザーがフォームに入力したemailとpasswordを含むリクエストがWebサーバーに送信
  2. ユーザー情報検索

    • emailに基づいてデータベースからユーザー情報を取得
  3. ユーザー情報

    • 該当ユーザーのレコード(例: id, name, email, password_hashなど)
    • password_hashはbcryptなどでハッシュ化されたパスワード
  4. パスワード照合

    • ユーザー入力のpasswordとDBにあるpassword_hashを比較し、一致するかを確認
  5. パスワード一致した場合、セッションID生成、保存

    • セッションIDを生成(例: abcd1234)

    • セッションストアにセッションIDとユーザーIDを関連付けて保存

    • セッションID ユーザーID
      abcd1234 11
      xyz9876 12
      wqwe1234 13
  6. セッションIDをCookieに設定

    • レスポンスヘッダーにSet-Cookie: session_id=abcd1234; Path=/; HttpOnly を追加し、ブラウザに送信
  7. CookieにセッションIDを設定

    • ブラウザがセッションIDをCookieに保存し、以降のリクエストで送信

セッションベース認証のログイン後の流れ

ユーザーがブラウザに保存されたセッションIDを使い、認証チェックを経て、ログイン済みユーザー向けのリソースを取得するまでの流れを示します。

詳細
  1. GET /secure (Cookie: session_id=abcd1234)
    • ブラウザがセッションID(例: abcd1234)をCookieに含めてリクエストを送信
  2. セッション情報検索
    • Webサーバーがセッションストアに対してセッションID(例: abcd1234)に対応する情報を問い合わせ
  3. セッションIDをもとにユーザーIDを検索
    • セッションストアがセッションIDに関連付けられたユーザー情報(例: ユーザーID 11)を返す
  4. ユーザー情報を取得
    • WebサーバーがユーザーIDを使ってデータベースから詳細なユーザー情報を取得
  5. ユーザー情報
    • 該当ユーザーのレコード(例: id, name, email, password_hash, roleなど)
  6. ユーザーのアクセス権を判定
    • ユーザーのロールや権限を確認し、/secureリソースへのアクセス権があるかチェック
  7. /secureのリソースを返す
    • アクセス権が確認できた場合、リソースをブラウザに返す
コード例
  • バックエンド
import express from 'express';
import session from 'express-session';

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

app.post('/login', async (req, res) => {
  const { email, password } = req.body;

  // データベースからユーザー情報を取得
  const user = await db.getUserByEmail(email);
  if (!user) {
    return res.status(401).json({ message: 'Unauthorized' });
  }

  // パスワード照合
  const isPasswordValid = await bcrypt.compare(password, user.password_hash);
  if (!isPasswordValid) {
    return res.status(401).json({ message: 'Unauthorized' });
  }

  // セッションID生成・保存
  req.session.userId = user.id;
  // ↑ ここで以下の2つが自動的に行われる
  // - セッションストアに (セッションID → ユーザーID) を保存
  // - レスポンスヘッダーに `Set-Cookie: session_id=abcd1234; Path=/; HttpOnly` を追加し、ブラウザに送信

  res.json({ message: 'Login successful' });
});

app.get('/secure', async (req, res) => {
  // セッションIDをもとにユーザーIDを取得
  const userId = req.session.userId;

  // データベースからユーザー情報を取得
  const user = await db.getUserById(userId);
  if (!user) {
   res.status(403).json({ message: 'Forbidden' });
  }

  // ユーザーのアクセス権を確認
  if (!user.hasAccessToSecureResource) {
   res.status(403).json({ message: 'Forbidden' });
  }

  // リソース返す
  res.json({ message: 'Access granted', user });
});

app.listen(3000, () => console.log('Server running on port 3000'));
  • フロントエンド
<body>
  <form id="loginForm" method="POST">
    <input type="email" id="email" name="email" required>
    <input type="password" id="password" name="password" required>
    <button type="submit">Login</button>
  </form>
</body>

セッションベース認証が満たす要件・満たさない要件

満たす要件

要件 評価 理由
同一オリジン・モノリシック対応 ✅ 優れている クッキーは同一オリジンで自動送受信され、最も自然な方式
セキュリティ(XSS対策) ✅ 優れている HttpOnly フラグでJavaScript からのアクセスを禁止可能
セキュリティ(トークン漏洩時) ✅ 優れている サーバー側でセッション即時無効化可能
実装の簡潔さ ✅ 優れている フロントエンドはクッキー管理をブラウザに任せればよく、シンプル

満たさない要件

要件 評価 理由
複数オリジン対応(SPA + API) ❌ 不得意 CORS の制限、SameSite フラグとの組み合わせで複雑化
モバイルアプリ対応 ❌ 使用不可 ネイティブアプリはクッキー機構を持たない
スケーラビリティ ❌ 不得意 セッション情報がサーバーに保存され、複数サーバー間での共有が必要

セキュリティ特性:CSRF対策(SameSite フラグ)

セッションベース認証では、セッションID をクッキーに保存します。ブラウザはリクエスト時に自動的にクッキーを送信するため、他のサイトから悪意あるリクエストを送られても、クッキーが付与されてしまうという問題があります。これを CSRF(Cross-Site Request Forgery)攻撃と呼びます。

SameSite フラグは、このリスクを軽減するためのクッキー属性です。

CSRF 攻撃の仕組み
【ユーザーの操作】
1. ユーザーが Twitter にログイン(セッションID がクッキーに保存される)
2. ユーザーが新しいタブで malicious.com を訪問

【悪いコードの実行】
3. malicious.com のページに隠しフォームが埋め込まれている:
   <form action="https://twitter.com/tweet" method="POST">
     <input name="text" value="詐欺サイトへ行きましょう">
     <script>document.forms[0].submit();</script>
   </form>

4. ユーザーの意図しないままフォームが自動送信される

5. ブラウザは Twitter 向けのリクエストであることを認識し、
   Twitter のクッキー(セッションID)を自動で付与して送信

6. サーバーは有効なセッションID を受け取ったため、
   リクエストが本人から来たと判断し、ツイートを投稿

【結果】
ユーザーの意図しないツイートが投稿される ❌

SameSite の 3 つの値

動作 ブラウザの判定 使い分け
Strict クロスオリジンのリクエストにクッキーを送らない リンククリックもクロスオリジンと判定 最も安全だが、利便性が低下
Lax(推奨) クロスオリジン GET リクエストには送るが、POST には送らない GET(リンククリック)は許可、POST は遮断 セキュリティと利便性のバランス ✅
None すべてのリクエストにクッキーを送る クロスオリジンでも送信 クロスオリジン API が必須の場合のみ(Secure 必須)

実装例

// Node.js + Express
import express from 'express';
import session from 'express-session';

const app = express();

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,    // XSS 対策:JavaScript からアクセス不可
    secure: true,      // HTTPS のみで送信
    sameSite: 'Lax',   // CSRF 対策:クロスオリジン POST で送らない
    maxAge: 3600000    // 1 時間で自動削除
  }
}));

app.post('/login', async (req, res) => {
  // ... ログイン処理
  req.session.userId = user.id;
  res.json({ message: 'Login successful' });
});

ポイント

  • ログイン後は、各リクエストでセッションストアとデータベースを参照し、ユーザー情報を取得して認証チェックを行う
  • セッションIDが漏洩しても、不正アクセスを検知すればサーバー側で無効化可能
  • HttpOnly フラグを適用したクッキーにセッションIDを保存することで、XSS の影響を受けにくい
  • Secure フラグを設定し、HTTPS 通信のみでセッションID を送信
  • SameSite=Lax 以上の設定で CSRF 対策を強化
  • ログイン試行回数制限やレート制限の実装を推奨

トークンベース認証

トークン(通常JWT)にユーザーIDなどの情報を含め、署名によって改ざん検知を行い、クライアント側で保持して認証を行う方式です。サーバーはステートレス(トークンを検証するだけ)になります。

トークンベース認証のログイン時の流れ

ユーザーがemailとpasswordを送信し、ブラウザにアクセストークンが設定されるまでの流れを示します。

詳細
  1. POST /login (email, password)

    • ユーザーがフォームに入力したemailとpasswordを含むリクエストをWebサーバーに送信
  2. ユーザー情報検索

    • emailに基づいてデータベースからユーザー情報を取得
  3. ユーザー情報

    • 該当ユーザーのレコード(例: id, name, email, password_hashなど)
    • password_hashはbcryptなどでハッシュ化されたパスワード
  4. パスワード照合

    • ユーザー入力のpasswordとDBにあるpassword_hashを比較し、一致するかを確認
  5. パスワード一致した場合、アクセストークン(JWT等)を生成

    • ペイロードにユーザー情報(例: id, role等)を含めることが多い(JWT)
    {
      "sub": 11,
      "name": "John Doe",
      "role": "admin",
      "iat": 1673330000,
      "exp": 1673333600
    }
    
  6. アクセストークンをブラウザへ送信

    • 生成したアクセストークンをレスポンスボディなどに含めてブラウザへ送信
  7. Cookieやローカルストレージ等にアクセストークンを保存

    • ブラウザ側で受け取ったアクセストークンをブラウザのCookieやローカルストレージ等に保存し、以降のリクエストで送信

トークンベース認証のログイン後の流れ

ユーザーがブラウザに保存されたアクセストークンを使い、認証チェックを経て、ログイン済みユーザー向けのリソースを取得するまでの流れを示します。

詳細
  1. GET /secure
    • ブラウザが保存したトークンをHTTPヘッダー(例: Authorization: Bearer <token>)に含めてリクエストを送信
  2. アクセストークンの検証
    • Webサーバーでアクセストークンを検証し、署名が正しいか、有効期限内などをチェック
    • 署名検証は秘密鍵や公開鍵を使って行う(HMACやRSAなど)
  3. ユーザー情報を取得
    • 検証したアクセストークンからユーザーIDなどを取得
  4. ユーザー情報
    • ユーザーID、ユーザー権限
  5. ユーザーのアクセス権を判定
    • ユーザーのロールや権限を確認し、/secureリソースへのアクセス権があるかチェック
  6. /secureのリソースを返す
    • アクセス権が確認できた場合、リソースをブラウザに返す
コード例
  • バックエンド
import express from 'express';
import jwt from 'jsonwebtoken';
import cookieParser from 'cookie-parser';

const app = express();
app.use(express.json());
app.use(cookieParser());
const accessTokenSecret = process.env.ACCESS_TOKEN_SECRET;
const refreshTokenSecret = process.env.REFRESH_TOKEN_SECRET;

app.post('/login', async (req, res) => {
  const { email, password } = req.body;

  // データベースからユーザー情報を取得
  const user = await db.getUserByEmail(email);
  if (!user) {
    return res.status(401).json({ message: 'Unauthorized' });
  }

  // パスワード照合
  const isPasswordValid = await bcrypt.compare(password, user.password_hash);
  if (!isPasswordValid) {
    return res.status(401).json({ message: 'Unauthorized' });
  }

  // アクセストークン(15分有効)を生成
  const accessToken = jwt.sign(
    { userId: user.id, role: user.role },
    accessTokenSecret,
    { algorithm: 'HS256', expiresIn: '15m' }
  );

  // リフレッシュトークン(7日有効)を生成
  const refreshToken = jwt.sign(
    { userId: user.id },
    refreshTokenSecret,
    { algorithm: 'HS256', expiresIn: '7d' }
  );

  // リフレッシュトークンをHttpOnly Cookieに設定
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'Strict',
    maxAge: 7 * 24 * 60 * 60 * 1000
  });

  // アクセストークンをレスポンスボディで返す
  res.json({ accessToken });
});

app.post('/refresh', (req, res) => {
  const refreshToken = req.cookies.refreshToken;

  if (!refreshToken) {
    return res.status(401).json({ message: 'Unauthorized' });
  }

  try {
    const decoded = jwt.verify(refreshToken, refreshTokenSecret);

    // 新しいアクセストークンを生成
    const newAccessToken = jwt.sign(
      { userId: decoded.userId, role: decoded.role },
      accessTokenSecret,
      { algorithm: 'HS256', expiresIn: '15m' }
    );

    res.json({ accessToken: newAccessToken });
  } catch (err) {
    return res.status(401).json({ message: 'Unauthorized' });
  }
});

app.get('/secure', async (req, res) => {
  // HTTPヘッダーからアクセストークンを取得
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ message: 'Unauthorized' });
  }
  const token = authHeader.split(' ')[1];

  // アクセストークンの検証
  let decoded;
  try {
    decoded = jwt.verify(token, accessTokenSecret);
  } catch (err) {
    return res.status(401).json({ message: 'Unauthorized' });
  }

  // ユーザーの存在と有効性を確認
  const user = await db.getUserById(decoded.userId);
  if (!user || !user.isActive) {
    return res.status(401).json({ message: 'Unauthorized' });
  }

  // ユーザーのアクセス権を確認
  if (user.role !== 'admin') {
    return res.status(403).json({ message: 'Forbidden' });
  }

  res.json({ message: 'Access granted', user });
});

app.listen(3000, () => console.log('Server running on port 3000'));
  • フロントエンド(インメモリ + リフレッシュトークン方式)
<body>
  <form id="loginForm">
    <input type="email" id="email" name="email" required>
    <input type="password" id="password" name="password" required>
    <button type="submit">Login</button>
  </form>
  <button id="fetchResource">Get Secure Data</button>

  <script>
    let accessToken = null;  // メモリにアクセストークンを保存
    const loginForm = document.getElementById('loginForm');

    loginForm.addEventListener('submit', async (event) => {
      event.preventDefault();
      const email = document.getElementById('email').value;
      const password = document.getElementById('password').value;

      try {
        const response = await fetch('/login', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          credentials: 'include',  // Cookieを含める
          body: JSON.stringify({ email, password })
        });

        if (response.ok) {
          const data = await response.json();
          accessToken = data.accessToken;  // メモリに保存
          alert('Login successful!');
        } else {
          alert('Login failed');
        }
      } catch (error) {
        console.error('Error:', error);
      }
    });

    // リフレッシュトークンでアクセストークンを更新
    async function refreshAccessToken() {
      try {
        const response = await fetch('/refresh', {
          method: 'POST',
          credentials: 'include'  // リフレッシュトークンはCookieから自動送信
        });

        if (response.ok) {
          const data = await response.json();
          accessToken = data.accessToken;
          return true;
        } else {
          // リフレッシュ失敗 → ログインし直す
          alert('Session expired. Please login again.');
          accessToken = null;
          return false;
        }
      } catch (error) {
        console.error('Error refreshing token:', error);
        return false;
      }
    }

    // アクセストークンを使ってセキュアなリソースを取得
    async function fetchWithToken(url) {
      let response = await fetch(url, {
        headers: {
          'Authorization': `Bearer ${accessToken}`,
          'Content-Type': 'application/json'
        },
        credentials: 'include'
      });

      // アクセストークン期限切れの場合
      if (response.status === 401) {
        const refreshed = await refreshAccessToken();
        if (refreshed) {
          // 新しいアクセストークンで再リクエスト
          response = await fetch(url, {
            headers: {
              'Authorization': `Bearer ${accessToken}`,
              'Content-Type': 'application/json'
            },
            credentials: 'include'
          });
        }
      }

      return response;
    }

    document.getElementById('fetchResource').addEventListener('click', async () => {
      if (!accessToken) {
        alert('Please login first');
        return;
      }

      const response = await fetchWithToken('/secure');
      const data = await response.json();
      console.log(data);
    });
  </script>
</body>

トークンベース認証が満たす要件・満たさない要件

満たす要件

要件 評価 理由
複数オリジン対応(SPA + API) ✅ 優れている CookieのSameSite制約を受けず、より扱いやすい
モバイルアプリ対応 ✅ 優れている ネイティブアプリもトークンを保有・送信可能
スケーラビリティ ✅ 優れている サーバーはステートレス、複数サーバーへの負荷分散容易
マイクロサービス対応 ✅ 優れている トークンさえあれば複数のマイクロサービスで認証可能

満たさない要件

要件 評価 理由
セキュリティ(XSS対策) ❌ 困難 localStorage に保存するとXSS脆弱性がある(インメモリなら対策可能だが利便性低下)
トークン漏洩時の即時無効化 ❌ 困難 有効期限内は使用可能。ブラックリスト方式はサーバーがステートレスでなくなる
実装の簡潔さ ❌ 複雑 トークン保存場所、有効期限管理、リフレッシュトークンの組み合わせで複雑化

セキュリティ特性:XSS と CSRFのトレードオフ

トークンベース認証は、保存場所の選択によってセキュリティと利便性が大きく異なります。

3 つの保存パターン

1. インメモリ保存(最も安全)
// ブラウザのメモリに保存
let accessToken = null;

async function login(email, password) {
  const response = await fetch('/login', {
    method: 'POST',
    body: JSON.stringify({ email, password })
  });
  const data = await response.json();
  accessToken = data.token;  // メモリに保存
}

async function fetchSecure() {
  const response = await fetch('/secure', {
    headers: { 'Authorization': `Bearer ${accessToken}` }
  });
  return response.json();
}
特性 評価
XSS対策 ✅ 優れている(XSS発生時も盗まれるのは実行中のみ、リロード・再起動で消える)
CSRF対策 ✅ 優れている(トークンがクッキーにない)
ページリロード ❌ トークン消失(リフレッシュトークン必須)
実装コスト ❌ 高い(リフレッシュトークン機構が必須)
2. localStorage 保存(手軽だが脆弱)
// ローカルストレージに保存
async function login(email, password) {
  const response = await fetch('/login', {
    method: 'POST',
    body: JSON.stringify({ email, password })
  });
  const data = await response.json();
  localStorage.setItem('authToken', data.token);  // localStorage に保存
}

async function fetchSecure() {
  const token = localStorage.getItem('authToken');
  const response = await fetch('/secure', {
    headers: { 'Authorization': `Bearer ${token}` }
  });
  return response.json();
}
特性 評価
XSS対策 ❌ 脆弱(JavaScriptでアクセス可能)
CSRF対策 ✅ 優れている(トークンがクッキーにない)
ページリロード ✅ トークン保持(リロードでも消えない)
実装コスト ✅ 低い(シンプルな実装)
3. クッキー保存 + HttpOnly フラグ
// バックエンド:クッキーにトークンを保存
app.post('/login', (req, res) => {
  const token = generateToken(user);
  res.cookie('accessToken', token, {
    httpOnly: true,  // JavaScript からアクセス不可
    secure: true,    // HTTPS のみ
    sameSite: 'Strict'  // CSRF 対策
  });
  res.json({ message: 'Login successful' });
});

// フロントエンド:Cookie は自動送信される
async function fetchSecure() {
  const response = await fetch('/secure');  // クッキーが自動で送信される
  return response.json();
}
特性 評価
XSS対策 ✅ 優れている(HttpOnly で保護)
CSRF対策 △ SameSite フラグで対応
ページリロード ✅ トークン保持(ブラウザが管理)
実装コスト ✅ 低い(クッキー自動送信)

トークン漏洩時の対応方法

アクセストークンが漏洩した場合、有効期限内は使用可能です。以下 3 つの対応方法があります:

1. 短い有効期限を設定

const token = jwt.sign(payload, secret, { expiresIn: '5m' });  // 5分
メリット デメリット
シンプル ユーザーが 5分ごとに再認証が必要(利便性低下)
無効化不要 リスク期間が短いだけで「無効化」ではない

2. ブラックリスト方式

// サーバー側:無効化したトークンをブラックリストに記録
const blacklistedTokens = new Set();

app.post('/logout', (req, res) => {
  const token = req.headers.authorization.split(' ')[1];
  blacklistedTokens.add(token);  // ブラックリストに追加
  res.json({ message: 'Logout successful' });
});

// 認証時にチェック
app.get('/secure', (req, res) => {
  const token = req.headers.authorization.split(' ')[1];
  if (blacklistedTokens.has(token)) {
    return res.status(401).json({ message: 'Token revoked' });
  }
  // 認証処理続行
});

本番環境では、Set() はプロセス内メモリのみのため、複数サーバー間でブラックリストが共有されません。Redis や Memcached などの共有ストアを使用してブラックリスト情報を保存する必要があります。

3. リフレッシュトークン方式(推奨)

// ログイン時:短命のアクセストークン + 長寿命のリフレッシュトークン
app.post('/login', (req, res) => {
  const accessToken = jwt.sign(payload, secret, { expiresIn: '15m' });
  const refreshToken = jwt.sign(payload, refreshSecret, { expiresIn: '7d' });

  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'Strict'
  });
  res.json({ accessToken });
});

// アクセストークン期限切れ時:リフレッシュトークンで新規取得
app.post('/refresh', (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  const payload = jwt.verify(refreshToken, refreshSecret);
  const newAccessToken = jwt.sign(payload, secret, { expiresIn: '15m' });
  res.json({ accessToken: newAccessToken });
});

ポイント

  • ログイン後は、各リクエストでアクセストークンを使ってユーザー情報を取得し、認証する

    • 各リクエストでアクセストークンの署名を検証し、ユーザーIDや権限を復元
    • セッションストアやデータベースと連携する必要なし(無効化が必要な場合は除く)
  • JWT 検証時は署名検証だけでなく、データベースでもユーザーの存在と有効性を確認する必要がある

    • トークンペイロードからユーザー情報を復元するだけでは、ユーザー削除や無効化に対応できない
  • HTTPS 通信を前提とする(パスワードとトークンの盗聴対策)

  • XSS 対策として Content Security Policy (CSP) の設定を推奨

  • ログイン試行回数制限やレート制限の実装を推奨

条件パターン別ガイド

これまで、セッションベース認証とトークンベース認証の実装と特性を理解しました。ここでは、実際のプロジェクトの条件パターンごとに、どの認証方式(またはパターン)が最適かを提案します。


パターン1:モノリシック(例: Rails, Django)or 同一オリジン(例: Rails + React)

条件

  • クライアント(フロントエンド)とサーバー(API)が同一オリジン
  • XSS 対策の優先度が高い
  • スケールアウト時にセッション共有機構がある、または単一サーバーで十分

推奨方式:セッションベース認証

特性 理由
実装の簡潔さ フロントエンドはクッキーをブラウザに任せるだけ
セキュリティ HttpOnly + Secure + SameSite=Lax で XSS・CSRF 対策可能
ユーザー体験 セッション長期保持で、ログイン頻度を減らせる

注意点

  • スケールアウト時はセッション共有(Redis等)が必須
  • CSRF トークン検証の実装を忘れずに

パターン2:SPA(例: React / Next.js + S3)+ APIサーバー(例: Rails / Node.js)

条件

  • フロント(app.example.com)と API(api.example.com)が異なるオリジン
  • XSS 対策の優先度が高い
  • スケーラビリティが必要
  • ユーザーが長期間ログイン状態を保ちたい

推奨方式:トークンベース認証(アクセストークン + リフレッシュトークン) × インメモリ/クッキー保存

※ 異なるオリジン間でも credentials: 'include' + Access-Control-Allow-Credentials: true で Cookie 使用は技術的に可能ですが、CORS・SameSite・Secure の設定が必要なため実装が複雑になり、トークンベース認証が採用されることが多いです。

アクセストークン保存 アクセストークン有効期限 リフレッシュトークン セキュリティ 利便性 実装コスト
インメモリ 短期(15分) クッキー(HttpOnly) ✅ 最高 ○ 良好 △ 高い
クッキー 短期(15分) クッキー(HttpOnly) ✅ 良好 ○ 良好 ○ 中程度

実装例:リフレッシュトークン方式

// バックエンド
app.post('/login', async (req, res) => {
  const user = await authenticateUser(req.body);
  const accessToken = generateAccessToken(user);  // 15分有効
  const refreshToken = generateRefreshToken(user);  // 7日有効

  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'Strict'
  });

  res.json({ accessToken });
});

app.post('/refresh', (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  const user = verifyRefreshToken(refreshToken);
  const newAccessToken = generateAccessToken(user);
  res.json({ accessToken: newAccessToken });
});

// フロントエンド
let accessToken = null;

async function login(email, password) {
  const res = await fetch('https://api.example.com/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'include',  // クッキー送信
    body: JSON.stringify({ email, password })
  });
  const data = await res.json();
  accessToken = data.accessToken;  // メモリに保存
}

async function fetchWithToken(url) {
  let res = await fetch(url, {
    headers: { 'Authorization': `Bearer ${accessToken}` },
    credentials: 'include'
  });

  // アクセストークン期限切れの場合
  if (res.status === 401) {
    await refreshAccessToken();
    res = await fetch(url, {
      headers: { 'Authorization': `Bearer ${accessToken}` },
      credentials: 'include'
    });
  }

  return res.json();
}

async function refreshAccessToken() {
  const res = await fetch('https://api.example.com/refresh', {
    method: 'POST',
    credentials: 'include'  // リフレッシュトークンはクッキーから自動送信
  });
  const data = await res.json();
  accessToken = data.accessToken;
}

注意点

  • CORS 設定で credentials: 'include' を許可
  • リフレッシュトークンはホワイトリスト管理が必要な場合がある

パターン3:モバイルアプリ(React Native、Flutter等)

条件

  • ネイティブアプリのためクッキー機構がない
  • 複数のバックエンドサービスと連携する場合がある
  • セキュリティ(特にトークン盗難)の優先度が高い

推奨方式:トークンベース認証(アクセストークン + リフレッシュトークン) × インメモリ/セキュアストレージ保存

// React Native
import AsyncStorage from '@react-native-async-storage/async-storage';

async function login(email, password) {
  const res = await fetch('https://api.example.com/login', {
    method: 'POST',
    body: JSON.stringify({ email, password })
  });
  const data = await res.json();

  // アクセストークン:メモリに保存
  accessToken = data.accessToken;

  // リフレッシュトークン:セキュアストレージに保存
  await AsyncStorage.setItem('refreshToken', data.refreshToken);
}

async function fetchWithToken(url) {
  let res = await fetch(url, {
    headers: { 'Authorization': `Bearer ${accessToken}` }
  });

  if (res.status === 401) {
    const refreshToken = await AsyncStorage.getItem('refreshToken');
    const res = await fetch('https://api.example.com/refresh', {
      method: 'POST',
      body: JSON.stringify({ refreshToken })
    });
    const data = await res.json();
    accessToken = data.accessToken;

    res = await fetch(url, {
      headers: { 'Authorization': `Bearer ${accessToken}` }
    });
  }

  return res.json();
}

注意点

  • セキュアストレージ(Keychain、Keystore等)を使用
  • リフレッシュトークンもリクエストボディに含めて送信

パターン4:マイクロサービス

条件

  • 複数のバックエンドサービスが存在
  • 各サービスが独立してスケールアウトする必要がある
  • サービス間での認証情報共有が必要

推奨方式:トークンベース認証(JWT)× 無効化機構は最小限

特徴:

  • JWT は署名さえ検証すればよく、サーバー間での情報共有不要
  • 有効期限は短めに設定(15~30分)
  • 必要に応じてブラックリスト/ホワイトリスト機構を追加

実務での採用傾向

企業システムでの採用実績から見た傾向をまとめます。

アーキテクチャ 採用される認証方式 主な理由
SSR(Rails / Django / Laravel 等) セッションベース認証 実装シンプル、セキュリティ対策が容易、同一オリジン前提で問題ない
SPA + 同一オリジン API セッションベース認証 Cookie 設定で対応可能、スケーラビリティも Redis で解決
SPA + 異なるオリジン API トークンベース認証 CORS の制限を回避でき、スケーラビリティに優れる
モバイルアプリ トークンベース認証 Cookie 機構がないため、必然的にトークン採用
マイクロサービス トークンベース認証(JWT) サービス間で認証情報共有が必要、ステートレスなため拡張性高い

判断フロー

プロジェクトがどのパターンに該当するか判断するための簡単なフロー図:

┌─ クライアント・サーバーが同一オリジン?

├─ YES → セッションベース認証 ✅

└─ NO(異なるオリジン)

   ├─ モバイルアプリか?
   │  ├─ YES → トークンベース認証(リフレッシュトークン + セキュアストレージ)
   │  └─ NO(Web の SPA)
   │     │
   │     ├─ XSS 対策最優先か?
   │     │  ├─ YES → トークンベース認証(インメモリ + リフレッシュトークン)
   │     │  └─ NO → トークンベース認証(localStorage)⚠️
   │     │
   │     └─ リスク:localStorage は XSS 脆弱性あり

   └─ マイクロサービスか?
      └─ YES → トークンベース認証(JWT)× 短い有効期限

参考資料

https://www.cloudflare.com/ja-jp/learning/access-management/token-based-authentication/
https://www.digitalocean.com/community/tutorials/nodejs-jwt-expressjs
https://auth0.com/blog/jp-refresh-tokens-what-are-they-and-when-to-use-them/

Discussion