はじめに
「本番APIキーを開発者が全員共有」「流出したキーをすぐに無効化できない」——スコープ付きAPIキー・即時失効・使用量追跡をClaude Codeに設計させる。
CLAUDE.mdにAPIキー管理設計ルールを書く
## APIキー管理設計ルール
### キー設計
- フォーマット: {prefix}_{random64bytes_base62}(例: sk_live_xxxxx)
- DBにはSHA256ハッシュのみ保存(プレーンは初回のみ表示)
- prefix でキーの種別を識別: sk(secret), pk(public), rk(restricted)
### スコープ管理
- スコープリスト: orders:read, orders:write, users:read, admin:*
- キーごとにスコープを制限(最小権限)
- スコープは作成時に固定(変更不可、再作成が必要)
### 使用量・監視
- 使用ごとにRedisカウンター更新(minute/hour/dayバケット)
- 不審なアクセスパターン: 1分100回超 → アラート
- キーのIPバインド(オプション): 特定IPのみ使用可
APIキー管理の生成
スコープ付きAPIキー管理システムを設計してください。
要件:
- スコープ付きキー生成
- ハッシュ保存
- 即時失効
- 使用量追跡
- Redis認証キャッシュ
生成ファイル: src/auth/apiKey/
生成されるAPIキー管理実装
// src/auth/apiKey/generator.ts — キー生成
import crypto from 'crypto';
type KeyPrefix = 'sk_live' | 'sk_test' | 'rk';
interface GeneratedKey {
plaintext: string; // 一度だけユーザーに表示(保存しない)
prefix: string;
hash: string; // DBに保存
hint: string; // 末尾4文字(UI表示用)
}
export function generateAPIKey(prefix: KeyPrefix): GeneratedKey {
const random = crypto.randomBytes(48).toString('base64url');
const plaintext = `${prefix}_${random}`;
const hash = crypto.createHash('sha256').update(plaintext).digest('hex');
const hint = plaintext.slice(-4);
return { plaintext, prefix, hash, hint };
}
// src/auth/apiKey/service.ts — キー管理サービス
export class APIKeyService {
async createKey(options: {
userId: string;
name: string;
scopes: string[];
expiresAt?: Date;
allowedIPs?: string[];
}): Promise<{ key: APIKey; plaintext: string }> {
const validScopes = await this.getAvailableScopes(options.userId);
const invalidScopes = options.scopes.filter(s => !this.isScopeGranted(s, validScopes));
if (invalidScopes.length > 0) {
throw new ValidationError(`Invalid scopes: ${invalidScopes.join(', ')}`);
}
const generated = generateAPIKey('sk_live');
const key = await prisma.apiKey.create({
data: {
userId: options.userId,
name: options.name,
keyHash: generated.hash,
keyHint: generated.hint,
scopes: options.scopes,
expiresAt: options.expiresAt,
allowedIPs: options.allowedIPs ?? [],
lastUsedAt: null,
revokedAt: null,
},
});
return { key, plaintext: generated.plaintext };
}
async authenticateKey(plaintext: string, requiredScope: string): Promise<{
valid: boolean;
userId?: string;
keyId?: string;
}> {
const hash = crypto.createHash('sha256').update(plaintext).digest('hex');
// Redisキャッシュを先に確認(1分TTL)
const cacheKey = `apikey:${hash}`;
const cached = await redis.get(cacheKey);
let keyData: APIKeyCache | null = cached ? JSON.parse(cached) : null;
if (!keyData) {
const key = await prisma.apiKey.findFirst({
where: {
keyHash: hash,
revokedAt: null,
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
},
select: { id: true, userId: true, scopes: true, allowedIPs: true },
});
if (!key) return { valid: false };
keyData = { id: key.id, userId: key.userId, scopes: key.scopes, allowedIPs: key.allowedIPs };
await redis.set(cacheKey, JSON.stringify(keyData), { EX: 60 });
}
if (!this.isScopeGranted(requiredScope, keyData.scopes)) {
return { valid: false };
}
setImmediate(() => this.trackUsage(keyData!.id, keyData!.userId));
return { valid: true, userId: keyData.userId, keyId: keyData.id };
}
private isScopeGranted(required: string, granted: string[]): boolean {
return granted.some(g => {
if (g === 'admin:*') return true;
if (g === required) return true;
if (g.endsWith(':*')) {
const namespace = g.slice(0, -2);
return required.startsWith(`${namespace}:`);
}
return false;
});
}
// 即時失効(Redisキャッシュも削除)
async revokeKey(keyId: string, userId: string): Promise<void> {
const key = await prisma.apiKey.findFirst({ where: { id: keyId, userId } });
if (!key) throw new NotFoundError('API key not found');
await prisma.apiKey.update({
where: { id: keyId },
data: { revokedAt: new Date() },
});
// Redisキャッシュを即座に無効化
await redis.del(`apikey:${key.keyHash}`);
}
private async trackUsage(keyId: string, userId: string): Promise<void> {
const now = Date.now();
const minBucket = Math.floor(now / 60_000);
const hourBucket = Math.floor(now / 3_600_000);
const dayBucket = Math.floor(now / 86_400_000);
const pipeline = redis.pipeline();
pipeline.incr(`apikey:usage:min:${keyId}:${minBucket}`);
pipeline.expire(`apikey:usage:min:${keyId}:${minBucket}`, 120);
pipeline.incr(`apikey:usage:hour:${keyId}:${hourBucket}`);
pipeline.expire(`apikey:usage:hour:${keyId}:${hourBucket}`, 7200);
pipeline.incr(`apikey:usage:day:${keyId}:${dayBucket}`);
pipeline.expire(`apikey:usage:day:${keyId}:${dayBucket}`, 172800);
await pipeline.exec();
const minuteCount = parseInt(await redis.get(`apikey:usage:min:${keyId}:${minBucket}`) ?? '0');
if (minuteCount > 100) {
Sentry.captureMessage('API key rate anomaly detected', {
level: 'warning',
extra: { keyId, userId, minuteCount },
});
}
}
}
// src/auth/apiKey/middleware.ts — Express統合
export function requireAPIKey(scope: string) {
const service = new APIKeyService();
return async (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;
const key = authHeader?.replace('Bearer ', '') ?? req.headers['x-api-key'] as string;
if (!key) return res.status(401).json({ error: 'API key required' });
const result = await service.authenticateKey(key, scope);
if (!result.valid) return res.status(403).json({ error: 'Invalid key or insufficient scope' });
req.userId = result.userId!;
req.apiKeyId = result.keyId!;
next();
};
}
// 使用例
router.get('/api/orders', requireAPIKey('orders:read'), async (req, res) => {
const orders = await prisma.order.findMany({ where: { userId: req.userId } });
res.json(orders);
});
まとめ
Claude CodeでAPIキー管理を設計する:
- CLAUDE.md にSHA256ハッシュ保存・初回のみplaintext表示・スコープ固定・Redisキャッシュ1分を明記
-
prefix_random64 フォーマットでキー種別を視認可能——
sk_live_なら本番キーとすぐわかる - Redis 1分キャッシュ で毎リクエストのDB参照を回避——revoke時はキャッシュも即削除
- 使用量追跡 はmin/hour/dayバケットでRedisに記録——1分100回超で自動アラート、last_used_atは1分に1回のDB更新
セキュリティ設計のレビューは **Security Pack(¥1,480)* の /security-check で確認できます。*
みょうが (@myougatheaxo) — ウーパールーパーのVTuber。
Top comments (0)