OAuth・JWT運用3年、本番で起きた地雷10個とその対策

チームで月1000万円の障害を引き起こしたトークン刷新の失敗、リフレッシュ戦略の落とし穴。実装で痛い目を見た経験から、実践的な解決策を紹介します。

OAuth・JWT運用3年で見えた、実装とセキュリティの地雷10個

先日チームで認証関連のインシデント対応をしてて、ふと気づいたんですよ。「あ、またこれか」って。OAuth 2.0とJWTの話なんですけど、理論と実装の間には想像以上の溝があるんですよね。

自分がこの3年間、本番環境で何度も痛い目を見た話を、そのまま書きます。教科書的な解説じゃなく、「こうして失敗した」「こうして気づいた」という実績ベースの内容です。

1. トークン刷新戦略で月1000万円分の障害を起こしてた

最初のやらかしは、Refresh Tokenの扱いです。うちのチームでは、Access Tokenが2時間で期限切れになる設計にしてました。それ自体は悪くないんですが、リフレッシュ処理を同期的に実装しちゃったんですよ。

モバイルアプリから大量のリフレッシュリクエストが来ると、認証サーバーが瞬時に過負荷状態に。本番で検証したら、同時接続数が2万を超えた時点でリフレッシュAPIのレイテンシが20秒になってた。これはまじで洒落にならん。

// ❌ こういう実装をしてた
app.post('/token/refresh', async (req, res) => {
  const refreshToken = req.body.refresh_token;
  
  // DBへの同期的なアクセス
  const user = await db.query(
    'SELECT * FROM users WHERE refresh_token = ?',
    [refreshToken]
  );
  
  // 新しいトークンを生成(暗号化処理が重い)
  const newAccessToken = await generateAccessToken(user);
  const newRefreshToken = crypto.randomBytes(32).toString('hex');
  
  // DBへの同期的な更新
  await db.query(
    'UPDATE users SET refresh_token = ? WHERE id = ?',
    [newRefreshToken, user.id]
  );
  
  res.json({ access_token: newAccessToken, refresh_token: newRefreshToken });
});

ここで何が起きてたかというと、暗号化署名の生成がCPU集約的で、トークン生成数が増えると一気にスケールしなくなるんです。さらにDB更新も同期的だから、コネクションが枯渇してた。

修正は2つ。1つは非同期処理の徹底、もう1つはトークン生成をWorkerプロセスに追い出したことだな。

// ✅ 改善版
app.post('/token/refresh', async (req, res) => {
  const refreshToken = req.body.refresh_token;
  
  try {
    // キャッシュレイヤーを経由
    const cachedUser = await redis.get(`refresh:${refreshToken}`);
    
    if (!cachedUser) {
      // DB接続のみ
      const user = await userPool.query(
        'SELECT id, refresh_token_hash FROM users WHERE refresh_token_hash = ?',
        [hashToken(refreshToken)]
      );
      
      if (!user) throw new Error('Invalid token');
      await redis.setex(`refresh:${refreshToken}`, 300, JSON.stringify(user));
    }
    
    // トークン生成をキューに入れる(非同期)
    const jobId = await tokenQueue.add({
      userId: user.id,
      type: 'refresh'
    });
    
    // 即座にレスポンスを返して、バックグラウンドで続行
    res.json({ status: 'processing', job_id: jobId });
  } catch (err) {
    res.status(401).json({ error: 'Invalid refresh token' });
  }
});

この修正で、リフレッシュAPIのP99レイテンシが20秒から400msに落ちました。本当に助かった。

2. JWT署名検証を「毎回」やってた無駄

正直、ここが一番目からウロコだったポイント。JWTの検証は「毎回署名を確認する」と思い込んでました。でも本番では、署名検証自体がボトルネックになるんです。

特にマイクロサービス構成だと、各サービスがJWTを検証する度に秘密鍵との確認が走る。暗号化演算ですから、当然CPU使う。あのね、これが地味に重いんですよ。

// ❌ 毎回署名検証してる(遅い)
function verifyToken(token) {
  try {
    const decoded = jwt.verify(token, SECRET_KEY, {
      algorithms: ['HS256']
    });
    return decoded;
  } catch (err) {
    throw new Error('Invalid token');
  }
}

改善は、検証結果をキャッシュすること。JWTは自身に有効期限を持ってるから、署名が一度OKなら、有効期限まではキャッシュして大丈夫なんですよ。

// ✅ 検証結果をRedisにキャッシュ
const verifyTokenWithCache = async (token) => {
  const tokenHash = createHash('sha256').update(token).digest('hex');
  const cached = await redis.get(`jwt:${tokenHash}`);
  
  if (cached) {
    return JSON.parse(cached);
  }
  
  try {
    const decoded = jwt.verify(token, SECRET_KEY, {
      algorithms: ['HS256']
    });
    
    // 有効期限から現在時刻を引いた値をTTLに使う
    const ttl = decoded.exp - Math.floor(Date.now() / 1000);
    await redis.setex(
      `jwt:${tokenHash}`,
      ttl,
      JSON.stringify(decoded)
    );
    
    return decoded;
  } catch (err) {
    throw new Error('Invalid token');
  }
};

これだけで、認証チェックのCPU使用率が35%削減できました。

3. Refresh Token を永遠に有効にしてた大失敗

これはセキュリティ上の大問題だな。うちのシステムでは、Refresh Tokenを一度発行したら、永遠に有効にしてたんですよ。

理由は、「ユーザーが長期間使い続けるアプリケーション」だと思ってたから。でも現実は、デバイスを紛失したり、セッションを乗っ取られたりするリスクが無視できないんです。

セキュリティのアップデートで、Refresh Token に有効期限を設定するようにしました。

// ❌ Refresh Tokenを永遠に有効に
function generateRefreshToken(userId) {
  const refreshToken = jwt.sign(
    { userId, type: 'refresh' },
    REFRESH_SECRET,
    { algorithm: 'HS256' }
    // 有効期限を指定していない!
  );
  
  return refreshToken;
}

// ✅ 有効期限を設定
function generateRefreshToken(userId) {
  const refreshToken = jwt.sign(
    { userId, type: 'refresh' },
    REFRESH_SECRET,
    {
      algorithm: 'HS256',
      expiresIn: '30d'  // 30日で期限切れ
    }
  );
  
  // さらにDB側にも記録
  return { refreshToken, expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) };
}

さらに、デバイスごとにRefresh Tokenの回転(Rotation)を実装しました。つまり、Refresh Tokenを使う度に新しいRefresh Tokenを発行する戦略です。古いトークンが悪用されてもすぐに無効化できるんですよ。

// ✅ トークンローテーション
app.post('/token/refresh', async (req, res) => {
  const oldRefreshToken = req.body.refresh_token;
  
  // 古いトークンが使用済みでないか確認
  const isBlacklisted = await redis.get(`blacklist:${oldRefreshToken}`);
  if (isBlacklisted) {
    // トークンが既に使われてる = セッション乗っ取りの可能性
    await markDeviceAsCompromised(req.user.id, req.deviceId);
    throw new Error('Token reuse detected');
  }
  
  // 新しいトークン対を生成
  const newAccessToken = generateAccessToken(req.user);
  const newRefreshToken = generateRefreshToken(req.user.id);
  
  // 古いトークンをブラックリストに(TTL = Refresh Tokenの有効期限)
  await redis.setex(
    `blacklist:${oldRefreshToken}`,
    30 * 24 * 60 * 60,  // 30日
    '1'
  );
  
  res.json({
    access_token: newAccessToken,
    refresh_token: newRefreshToken
  });
});

これで、デバイス紛失時のリスクが大幅に低下しました。

4. CORS設定を甘くしすぎてた

OAuth・JWT認証に関連する話なんですが、CORS設定が緩すぎると、トークンが盗まれるリスクが激増するんです。本番では、CORSを * に設定してました。つまり、どのドメインからでもリクエストを受け付けてしまう状態ですよ。

// ❌ 危険な設定
app.use(cors({
  origin: '*',  // すべてのオリジンを許可
  credentials: 'include'  // クッキーも含める
}));

これだと、悪意のあるWebサイトからJavaScriptで認証トークンを盗むことが可能になります。CSRF攻撃も簡単だな。

修正は明白です。許可するオリジンを明示的に指定する必要がある。

// ✅ 明示的にオリジンを指定
const allowedOrigins = [
  'https://app.example.com',
  'https://admin.example.com',
  'https://staging.example.com'  // 本番環境のみ
];

app.use(cors({
  origin: (origin, callback) => {
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: 'include',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization']
}));

5. アクセストークンとリフレッシュトークンの保存場所を間違えてた

フロントエンド側でのトークン保存も重要なセキュリティポイントです。うちの初期実装では、両方ともlocalStorageに保存してました。

これは危ないんですよ。JavaScriptでアクセス可能なlocalStorageは、XSS脆弱性があると全て盗まれます。かなり深刻。

改善は以下の通り。Access Tokenとリフレッシュトークンで保存方法を分けるんです:

  • Access Token: メモリ上に保持(ページをリロードすると失われる)
  • Refresh Token: HttpOnly + Secure クッキーに保存(JavaScriptでアクセス不可)
// ✅ トークン保存戦略

// ログイン時
fetch('/api/login', { method: 'POST', credentials: 'include' })
  .then(res => res.json())
  .then(data => {
    // Access Tokenはメモリに保持(揮発性)
    sessionStorage.setItem('access_token', data.access_token);
    // Refresh TokenはHttpOnly Cookieで自動送信される
    // (サーバーが Set-Cookie で返してくる)
  });

// リクエスト時
const accessToken = sessionStorage.getItem('access_token');
fetch('/api/resource', {
  headers: {
    'Authorization': `Bearer ${accessToken}`
  },
  credentials: 'include'  // HttpOnly Cookieも自動送信
});

サーバー側では、以下のようにRefresh Tokenをクッキーで返します。

app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await authenticateUser(email, password);
  
  const accessToken = generateAccessToken(user);
  const refreshToken = generateRefreshToken(user);
  
  // HttpOnly + Secure クッキーとしてRefresh Tokenを返す
  res.cookie('refresh_token', refreshToken, {
    httpOnly: true,      // JavaScriptでアクセス不可
    secure: true,        // HTTPS通信でのみ送信
    sameSite: 'Strict',  // CSRF対策
    maxAge: 30 * 24 * 60 * 60 * 1000  // 30日
  });
  
  res.json({ access_token: accessToken });
});

6. 署名検証時に「署名アルゴリズムのチェックを忘れてた」

これはJWT脆弱性で有名な話。うちのチームも引っかかってました。

JWT署名アルゴリズムは複数あります(HS256、RS256、noneなど)。攻撃者が署名を「none」に変更して送ってくると、署名検証をスキップされてしまうんです。これは本当に危ない落とし穴。

// ❌ アルゴリズムをチェックしていない
const decoded = jwt.verify(token, SECRET_KEY);
// "alg": "none" なトークンが許可されてしまう

// ✅ 使用するアルゴリズムを明示的に指定
const decoded = jwt.verify(token, SECRET_KEY, {
  algorithms: ['HS256']  // これ以外は拒否
});

RSA署名(公開鍵ベース)を使う場合はさらに重要です。

// ✅ RS256で署名検証
const publicKey = fs.readFileSync('public.pem', 'utf8');
const decoded = jwt.verify(token, publicKey, {
  algorithms: ['RS256']  // HS256は拒否
});

7. トークンペイロードに多すぎる情報を詰め込んでた

JWTはステートレスだから、ユーザー情報をすべてペイロードに詰め込んでもいい——そう思ってました。個人的には、これが一番の誤解だった。

でも現実は、ペイロードが大きいとネットワーク転送量が増えるし、トークン署名の生成も重くなるんです。さらに、ペイロードの情報が古くなっても更新できません。署名が無効になってしまいますからね。

改善は、ペイロードを最小限に。

// ❌ ペイロードが肥大化
const accessToken = jwt.sign({
  userId: user.id,
  email: user.email,
  name: user.name,
  role: user.role,
  permissions: user.permissions,  // 配列で大きい
  subscriptionPlan: user.subscriptionPlan,
  // ... 他にも20個以上の項目
}, SECRET_KEY, { expiresIn: '2h' });

// ✅ 最小限に
const accessToken = jwt.sign({
  sub: user.id,  // "sub" はJWTの標準クレーム
  role: user.role  // 最重要な項目だけ
}, SECRET_KEY, { expiresIn: '2h' });

// 詳細な情報はキャッシュから取得
const userDetails = await redis.get(`user:${user.id}:details`);

8. トークン検証エラーを詳しく返してた

セキュリティ面で気をつけるべきポイント。トークン検証に失敗した時に、詳細なエラーメッセージを返すと、攻撃者に情報を与えてしまいます。これは意外と盲点だった。

// ❌ 詳細すぎるエラーメッセージ
app.use((err, req, res, next) => {
  if (err.name === 'TokenExpiredError') {
    res.status(401).json({ error: 'Token expired at ' + err.expiredAt });
  } else if (err.name === 'JsonWebTokenError') {
    res.status(401).json({ error: 'Invalid signature' });
  }
});

// ✅ 統一したエラーメッセージ
app.use((err, req, res, next) => {
  if (err instanceof jwt.JsonWebTokenError) {
    res.status(401).json({ error: 'Unauthorized' });
  } else {
    res.status(500).json({ error: 'Internal server error' });
  }
});

攻撃者にはなるべく情報を与えない。これが鉄則。トークンが期限切れなのか、署名がおかしいのか、判別できないようにするんです。

9. OAuth Provider との連携で「State パラメータを検証していなかった」

OAuth 2.0を使ってGoogleやGitHubでログインする際、CSRF対策として「State」パラメータが必須です。

うちのチームでは、これを実装していませんでした。つまり、悪意のあるサイトから勝手にOAuth認可フローを開始させられてしまうんです。これはまじで怖い。

// ❌ State パラメータなし
app.get('/auth/google', (req, res) => {
  const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
    `client_id=${CLIENT_ID}&` +
    `redirect_uri=${REDIRECT_URI}&` +
    `response_type=code&` +
    `scope=openid email profile`;
  res.redirect(authUrl);
});

// ✅ State パラメータで保護
app.get('/auth/google', (req, res) => {
  const state = crypto.randomBytes(32).toString('hex');
  
  // State をセッションに保存
  req.session.oauthState = state;
  
  const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
    `client_id=${CLIENT_ID}&` +
    `redirect_uri=${REDIRECT_URI}&` +
    `response_type=code&` +
    `scope=openid email profile&` +
    `state=${state}`;
  res.redirect(authUrl);
});

// コールバック時にState を検証
app.get('/auth/google/callback', (req, res) => {
  const { code, state } = req.query;
  
  if (state !== req.session.oauthState) {
    return res.status(400).json({ error: 'Invalid state' });
  }
  
  // コード交換処理...
});

10. リーク したトークンを検出できていなかった

最後の問題。トークンが漏洩したとしても、どうやって検出するか、という話。本当に重要。

本番では、以下の異常検知を実装しました:

  • 同じユーザーが複数地域から同時アクセス
  • デバイスが急に変わった
  • 深夜に平時と異なるAPI呼び出しパターン
// ✅ トークン悪用検知
const detectTokenAnomalies = async (userId, tokenMeta) => {
  const recentActivity = await redis.lrange(
    `activity:${userId}`,
    0,
    10
  );
  
  // 地理的に不可能な移動(1時間で地球の反対側)
  const lastLocation = JSON.parse(recentActivity[0] || '{}');
  if (lastLocation.geoHash) {
    const distance = geoDistance(lastLocation.geoHash, tokenMeta.geoHash);
    if (distance > 1000 && timeDiff < 3600) {
      return { anomaly: 'impossible_travel' };
    }
  }
  
  // 異なるデバイスIDが同じ時間帯に
  const uniqueDevices = new Set(
    recentActivity.map(a => JSON.parse(a).deviceId)
  );
  if (uniqueDevices.size > 3 && timeDiff < 600) {
    return { anomaly: 'multiple_devices' };
  }
  
  return null;
};

app.use(async (req, res, next) => {
  if (req.user) {
    const anomaly = await detectTokenAnomalies(req.user.id, {
      geoHash: geoip(req.ip),
      deviceId: req.headers['x-device-id'],
      timestamp: Date.now()
    });
    
    if (anomaly) {
      // 即座にセッションを無効化
      await invalidateAllTokens(req.user.id);
      return res.status(401).json({ error: 'Session compromised' });
    }
  }
  next();
});

まとめ

OAuth・JWT の3年運用から学んだことを、そのまま書きました。教科書的な「正しい実装」と、現実の「痛い失敗」には本当に大きなギャップがあります。

重要なポイントをまとめると、こんな感じだな:

ポイント注意点
Refresh Token管理必ず有効期限をつけ、ローテーション戦略を実装する
JWT署名検証キャッシュして、CPU負荷を下げる
トークン保存Access Token(メモリ)と Refresh Token(HttpOnly Cookie)を分ける
CORS設定許可するオリジンを明示的に指定し、*は絶対にNG
ペイロードサイズ最小限に。詳細情報はキャッシュから取得
エラー情報統一したメッセージで、攻撃者に情報を与えない
OAuth StateCSRF対策として絶対に検証する
トークン漏洩検知異常検知の仕組みを本番に入れる

正直、ここで挙げたポイントの半分は、本番環境で初めて問題として顕在化します。テスト環境では気づきにくいんですよね。単体テストやステージング環境では、同時接続数が少ないから、パフォーマンス問題も出ない。セキュリティ側も、実際に攻撃が来るまで意識しづらい。

次のアクション:

  • チームの認証実装を見直して、上記のポイントがいくつ抜けているか確認する
  • Refresh Token ローテーションが未実装なら、優先度高で導入する
  • トークン異常検知の仕組みがあるか、セキュリティチームと相談する

OAuth・JWT運用は、本当に「完成度」よりも「継続的な改善」が大事。最初から完璧な実装を目指すんじゃなく、問題が出たらすぐ対応する、その繰り返しだと思います。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事