メインコンテンツへスキップ
三田工場 技術サイト
WebアプリをAIから操作できるようにした話(第3回)— Bearer Token認証とDynamoDBによるトークン管理

WebアプリをAIから操作できるようにした話(第3回)— Bearer Token認証とDynamoDBによるトークン管理

Architecture10分で読めます

このシリーズ: 全4回

  1. 第1回: MCPサーバー化の動機とアーキテクチャ
  2. 第2回: MCP Streamable HTTPの実装
  3. 第3回: Bearer Token認証とDynamoDBトークン管理 ← 今ここ
  4. 第4回: Claude Codeから繋げるときの落とし穴

概要

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 を直接書けるシンプルさを優先しました。

json
{
  "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種類のアクセスパターンがあります:

  1. ユーザーIDでトークン一覧を取得(設定画面での表示)
  2. 受け取ったトークンのハッシュで認証(リクエスト時の検証)

DynamoDB の Single Table Design で両方に対応するため、デュアルレコードパターンを使います。1つのトークンに対して2行のレコードを作成します:

text
// メインレコード(ユーザー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) で認証できます。

実装

トークン発行

typescript
// 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に保存します。

リクエスト認証

typescript
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 ツールの各ハンドラーで、操作対象のチームにユーザーが所属しているかを確認します。

typescript
// プロジェクト一覧取得
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 });
}

タスクやアイテムはプロジェクトを通じて間接的にチームに属するため、連鎖解決が必要です:

typescript
// タスク更新
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エンドポイント(トークン管理)

設定画面からトークンを発行・失効できるエンドポイントを作成します。

typescript
// 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 });
}
typescript
// 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を実装します。

tsx
// 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) で実現
  • 認可はツールのハンドラー内でチームメンバーシップをチェック
  • 認可チェックは存在確認より前に実行(情報漏洩防止)

バイブコーディングで実装する

text
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つの問題と解決方法を解説します。

関連記事