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 State | CSRF対策として絶対に検証する |
| トークン漏洩検知 | 異常検知の仕組みを本番に入れる |
正直、ここで挙げたポイントの半分は、本番環境で初めて問題として顕在化します。テスト環境では気づきにくいんですよね。単体テストやステージング環境では、同時接続数が少ないから、パフォーマンス問題も出ない。セキュリティ側も、実際に攻撃が来るまで意識しづらい。
次のアクション:
- チームの認証実装を見直して、上記のポイントがいくつ抜けているか確認する
- Refresh Token ローテーションが未実装なら、優先度高で導入する
- トークン異常検知の仕組みがあるか、セキュリティチームと相談する
OAuth・JWT運用は、本当に「完成度」よりも「継続的な改善」が大事。最初から完璧な実装を目指すんじゃなく、問題が出たらすぐ対応する、その繰り返しだと思います。