
WebアプリをAIから操作できるようにした話(第3回)— Bearer Token認証とDynamoDBによるトークン管理
このシリーズ: 全4回
概要
MCPサーバーをインターネットに公開するからには、適切な認証と認可が必須です。
今回の実装では:
- 認証: Bearer Token(SHA-256ハッシュ化してDynamoDBに保存)
- 認可: チームメンバーシップ(操作対象のチームに所属しているか)
の2段構えで保護します。
こんな人向け
- MCP サーバーに認証を実装したい
- パスワードと同様にトークンを安全にDBに保存したい(生トークンをDBに持ちたくない)
- DynamoDB で「トークン → ユーザー」の逆引きを高速に行いたい
認証設計
なぜBearerトークンか
MCPの仕様上、Claude Code からのリクエストはすべて HTTP ヘッダーに認証情報を含められます。OAuth 2.0 も選択肢ですが、Claude Code の settings.json に headers として Bearer Token を直接書けるシンプルさを優先しました。
{
"mcpServers": {
"team-task-scheduler": {
"type": "http",
"url": "https://your-app.cloudfront.net/api/mcp",
"headers": {
"Authorization": "Bearer <your-token>"
}
}
}
}トークンの安全な保存
生トークンをDBに保存するのは危険です(DB漏洩時にそのまま使われる)。パスワードと同様に ハッシュ化 して保存します。
今回は bcrypt ではなく SHA-256 を使います。理由:
- トークンは十分なエントロピーを持つランダム文字列(64文字の16進数)なので、ブルートフォースに対するコストは不要
- SHA-256 は決定論的なので、受け取ったトークンをハッシュ化して比較できる
- bcrypt より処理が速い(LatencyへのLambdaコールドスタートの影響を減らせる)
DynamoDBのデータ設計
MCP トークンのレコードは2種類のアクセスパターンがあります:
- ユーザーIDでトークン一覧を取得(設定画面での表示)
- 受け取ったトークンのハッシュで認証(リクエスト時の検証)
DynamoDB の Single Table Design で両方に対応するため、デュアルレコードパターンを使います。1つのトークンに対して2行のレコードを作成します:
// メインレコード(ユーザーIDで引ける)
PK: USER#<userId>
SK: MCP_TOKEN#<tokenId>
{
tokenId, name, hashedToken, userId, createdAt, lastUsedAt
}
// ハッシュ逆引きレコード(トークンハッシュで認証できる)
PK: MCP_TOKEN_HASH#<hashedToken>
SK: METADATA
{
tokenId, userId, name
}トークン検証時は PK = MCP_TOKEN_HASH#<hash(receivedToken)> で GetItem するだけです。O(1) で認証できます。
実装
トークン発行
// src/lib/mcp/auth.ts
import crypto from 'crypto';
export function generateToken(): { raw: string; hashed: string } {
const raw = crypto.randomBytes(32).toString('hex'); // 64文字
const hashed = crypto.createHash('sha256').update(raw).digest('hex');
return { raw, hashed };
}発行時は生トークンをユーザーに一度だけ表示し、ハッシュをDBに保存します。
リクエスト認証
export async function authenticateMcpRequest(
authHeader: string | null
): Promise<{ userId: string; tokenId: string } | null> {
if (!authHeader?.startsWith('Bearer ')) return null;
const rawToken = authHeader.slice(7); // "Bearer " を除く
if (!rawToken) return null;
const hashed = crypto.createHash('sha256').update(rawToken).digest('hex');
// DynamoDB でハッシュ逆引き
const record = await mcpTokenRepository.findByHash(hashed);
if (!record) return null;
// lastUsedAt を非同期更新(レスポンスを遅延させない)
mcpTokenRepository.updateLastUsed(record.tokenId, record.userId).catch(() => {});
return { userId: record.userId, tokenId: record.tokenId };
}lastUsedAt の更新は await しません。認証のレスポンスを遅延させないためです。
認可:チームメンバーシップチェック
認証(誰か)と認可(何ができるか)は別物です。MCP ツールの各ハンドラーで、操作対象のチームにユーザーが所属しているかを確認します。
// プロジェクト一覧取得
async handler(args, userId) {
const teamId = args.teamId as string;
// 認可チェック
const isMember = await teamRepository.isMember(teamId, userId);
if (!isMember) {
return errorResult('このチームへのアクセス権がありません');
}
const projects = await projectRepository.findByTeamId(teamId);
return json({ projects, count: projects.length });
}タスクやアイテムはプロジェクトを通じて間接的にチームに属するため、連鎖解決が必要です:
// タスク更新
async handler(args, userId) {
const taskId = args.taskId as string;
const task = await taskRepository.findById(args.projectId as string, taskId);
if (!task) return errorResult('タスクが見つかりません');
// タスク → プロジェクト → チーム の連鎖でチームIDを取得
const project = await projectRepository.findById(task.projectId);
const isMember = await teamRepository.isMember(project.teamId, userId);
if (!isMember) return errorResult('アクセス権がありません');
// 更新処理...
}情報漏洩防止: 認可チェックは存在確認より前に実行します。「このリソースは存在するがアクセス権がない」という情報も外部に漏らすべきではないため、先に認可確認を行います。
APIエンドポイント(トークン管理)
設定画面からトークンを発行・失効できるエンドポイントを作成します。
// src/app/api/mcp-tokens/route.ts
// GET: トークン一覧(tokenId, name, createdAt, lastUsedAt のみ。hashedToken は返さない)
export async function GET(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const tokens = await mcpTokenRepository.findByUserId(session.user.id);
return NextResponse.json({ tokens });
}
// POST: トークン発行(生トークンは1回のみ返す)
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { name } = await request.json() as { name: string };
const { raw, hashed } = generateToken();
await mcpTokenRepository.create({
userId: session.user.id,
name,
hashedToken: hashed,
});
// 生トークンはここでのみ返す。次回以降は取得不可
return NextResponse.json({ rawToken: raw, name }, { status: 201 });
}// src/app/api/mcp-tokens/[id]/route.ts
// DELETE: トークン失効
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const token = await mcpTokenRepository.findById(session.user.id, params.id);
if (!token) return NextResponse.json({ error: 'Not found' }, { status: 404 });
// 所有者確認(他人のトークンを削除できないように)
if (token.userId !== session.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// デュアルレコードを両方削除
await mcpTokenRepository.delete(session.user.id, params.id, token.hashedToken);
return new NextResponse(null, { status: 204 });
}設定画面UI
React コンポーネントで TanStack Query を使った管理UIを実装します。
// src/components/settings/McpTokenSettings.tsx(抜粋)
export function McpTokenSettings() {
const [newToken, setNewToken] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: (name: string) =>
fetch('/api/mcp-tokens', {
method: 'POST',
body: JSON.stringify({ name }),
}).then((r) => r.json()),
onSuccess: (data) => {
setNewToken(data.rawToken); // ← ここで1回だけ表示
queryClient.invalidateQueries({ queryKey: ['mcp-tokens'] });
},
});
return (
<>
{newToken && (
<div className="alert">
<p>トークンをコピーしてください。再表示はできません。</p>
<code>{newToken}</code>
<button onClick={() => navigator.clipboard.writeText(newToken)}>コピー</button>
</div>
)}
{/* トークン一覧・発行フォーム */}
</>
);
}生トークンは useState に一時保持し、ユーザーがコピーしたら表示を消せるようにします。DBには絶対に生トークンは保存しません。
まとめ
- Bearer Token を SHA-256 ハッシュ化して DynamoDB に保存する(生トークンは不保存)
- デュアルレコードパターンで「ユーザーID → トークン一覧」と「ハッシュ → 認証」の両方を O(1) で実現
- 認可はツールのハンドラー内でチームメンバーシップをチェック
- 認可チェックは存在確認より前に実行(情報漏洩防止)
バイブコーディングで実装する
Next.js + DynamoDB で MCP Bearer Token 認証を実装してください。
【トークンの仕様】
- 生トークン: crypto.randomBytes(32).toString('hex')(64文字の hex 文字列)
- 保存するのは SHA-256 ハッシュのみ。生トークンはDBに保存しない
- 発行時のみ生トークンをレスポンスに含める(次回以降は取得不可)
【DynamoDBのレコード設計(デュアルレコード)】
トークン1件につき2レコードを作成:
1. メインレコード: PK=USER#<userId>, SK=MCP_TOKEN#<tokenId>
2. 逆引きレコード: PK=MCP_TOKEN_HASH#<hashedToken>, SK=METADATA
認証時は PK=MCP_TOKEN_HASH#<hash(received_token)> で GetItem する。
【authenticateMcpRequest 関数】
- Authorization ヘッダーから "Bearer " プレフィックスを除いてトークンを取得
- SHA-256 ハッシュ化して DynamoDB で逆引き
- lastUsedAt の更新は await しない(レスポンス遅延防止)
- 戻り値: { userId, tokenId } | null
【認可】
各ツールハンドラー内で teamRepository.isMember(teamId, userId) を呼ぶ。
認可チェックはリソースの存在確認より前に実行すること。AIに指示するときのポイント
- デュアルレコードを明示する: 「1トークン = 2レコード」を説明しないと、1レコードで実装してフルスキャンで認証するコードが生成される
- lastUsedAt は非同期: await しない理由(レスポンス遅延防止)を伝えないと await を付けてしまう
- 情報漏洩防止の認可順序: 「存在確認より前に認可チェック」を明示しないと、存在確認を先にするコードが生成される
次回: 第4回: Claude Codeから繋げるときの落とし穴 では、接続時にハマった2つの問題と解決方法を解説します。