マルチテナント SaaS を構築する際、最初に検討するアーキテクチャ上の課題は通常「テナントデータをどのように分離するか」です。選択肢はテナントごとにデータベースを分離する(最大の分離性、最大のコスト)から、共有データベースでロウレベルフィルタリングを行う(最小のコスト、より慎重なコーディングが必要)まで多岐にわたります。

しかし、同等に重要でありながらあまり注目されない質問があります:API はリクエストがどのテナントコンテキストに属するかをどのように把握するのでしょうか?

この投稿では本番環境で実際に使用しているパターンを紹介します。テナントスコープを指定するカスタムリクエストヘッダーと JWT 認証を組み合わせた方法です。実装がシンプルで監査しやすく、単一のユーザーアカウントから複数テナントへのアクセスを柔軟にサポートできます。

3 つの一般的なアプローチ

1. サブドメイン方式 (tenant.yourdomain.com)

テナント情報がホスト名にエンコードされます。各サブドメインは同じバックエンドにルーティングされ、バックエンドは Host ヘッダーからテナントを抽出します。

利点: 直感的で URL に表示される。
欠点: ワイルドカード TLS 証明書が必要で DNS 設定が複雑、開発時に扱いにくく、モバイル API クライアントでは同様に機能しない。

2. URL パス方式 (/api/tenants/{tenantId}/...)

テナント識別子がすべてのルートパスに含まれます。

利点: RESTful で自己記述的。
欠点: すべてのルート定義が肥大化し、すべてのエンドポイントにテナントセグメントを含める必要があり、API バージョニングが煩雑になる。

3. ヘッダー方式 (x-tenant-id: <id>)

カスタムヘッダーがテナントコンテキストを伝えます。ルートはシンプルに保たれ、テナントスコープはハンドラ実行前にミドルウェアで解決されます。

利点: ルートがシンプルになり、ミドルウェアが一貫してスコープを処理し、JWT 認証と相性が良く、テストしやすい。
欠点: テナントが URL に表示されないため視認性が低い。クライアントは常にヘッダーを含める必要がある。

私たちはヘッダー方式を採用しています。

実装

API は 2 種類の認証を受け付けます:

  1. Authorization ヘッダー内の JWT トークン — 誰が リクエストを行っているかを識別
  2. x-tenant-id ヘッダー内のテナント ID — どのテナントの代わりに 実行されているかを識別
POST /api/v1/members
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
x-tenant-id: tenant_01GZ8K3X7Y
Content-Type: application/json

Enter fullscreen mode Exit fullscreen mode

ミドルウェア

認証ミドルウェアが最初に実行され JWT を検証します。テナントミドルウェアが次に実行され、x-tenant-id を認証済みユーザーの許可されたテナントと照合します:

// middleware/requireAuth.js
export async function requireAuth(req, res, next) {
  const token = extractBearerToken(req.headers.authorization);
  if (!token) return res.status(401).json({ error: 'Unauthorized' });

  try {
    const payload = verifyJwt(token);
    req.user = payload;
    next();
  } catch {
    res.status(401).json({ error: 'Invalid token' });
  }
}

Enter fullscreen mode Exit fullscreen mode

// middleware/requireTenantContext.js
export async function requireTenantContext(req, res, next) {
  const tenantId = req.headers['x-tenant-id'];
  if (!tenantId) return res.status(400).json({ error: 'x-tenant-id header required' });

  // Verify the authenticated user has access to this tenant
  const membership = await db.membership.findFirst({
    where: {
      userId: req.user.id,
      tenantId,
      status: 'active',
    },
  });

  if (!membership) return res.status(403).json({ error: 'Access denied' });

  req.tenantId = tenantId;
  req.role = membership.role;
  next();
}

Enter fullscreen mode Exit fullscreen mode

ルートハンドラは req.tenantIdreq.role を利用できます。これらのハンドラ内のすべてのデータベースクエリには where: { tenantId: req.tenantId } が含まれます。

ルート登録

テナントスコープ付きルートは両方のミドルウェアを適用します。パブリックルート(認証エンドポイント、ヘルスチェック)はどちらも適用しません:

// Public
router.post('/auth/login', loginHandler);
router.get('/health', healthHandler);

// Tenant-scoped
router.use('/members', requireAuth, requireTenantContext, membersRouter);
router.use('/invoices', requireAuth, requireTenantContext, invoicesRouter);
router.use('/settings', requireAuth, requireTenantContext, settingsRouter);

Enter fullscreen mode Exit fullscreen mode

ミドルウェアはハンドラごとではなくルータレベルで適用されます。テナントスコープ付きプレフィックスの下にある新しいルートは、追加の作業なしで自動的にコンテキストを継承します。

1 アカウントからのマルチテナントアクセス

ヘッダーパターンにより、もう一つのことが容易になります。単一のユーザーアカウントが複数のテナントにアクセスできることです。

スーパー管理者や管理ツールは、再認証することなくテナントを横断してクエリしたりコンテキストを切り替えたりする必要があります。ヘッダーパターンではこれは簡単です。ユーザー用に 1 つの JWT を発行し、リクエストごとに異なる x-tenant-id を渡すだけです:

// Management dashboard switching tenant context
async function fetchMembersForTenant(tenantId) {
  return api.get('/members', {
    headers: {
      'Authorization': `Bearer ${userToken}`,
      'x-tenant-id': tenantId,
    }
  });
}

Enter fullscreen mode Exit fullscreen mode

サブドメイン方式やパス方式では、同じシナリオで異なるベース URL や重複したルート構造が必要になります。

防衛深度の追加:ロウレベルセキュリティ

ヘッダー+ミドルウェアパターンはアプリケーションレイヤでのテナント分離を扱います。データベースレベルでさらに一層の防御を加えるために、PostgreSQL の Row-Level Security を使用すれば、アプリケーションバグで tenantId フィルタが省略された場合でも分離を強制できます:

-- Policy: users can only see rows belonging to their current tenant
ALTER TABLE members ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON members
  USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

Enter fullscreen mode Exit fullscreen mode

各リクエストの開始時に DB 接続に現在のテナントを設定します:

await db.$executeRaw`SELECT set_config('app.current_tenant_id', ${req.tenantId}, true)`;

Enter fullscreen mode Exit fullscreen mode

これにより、WHERE tenant_id = ? を忘れたクエリでもデータ漏洩ではなく空の結果が返されます。ミドルウェアが第一の防衛線で、RLS が最後の砦となります。

採用前に知っておくべきトレードオフ

クライアントは常にヘッダーを送信する必要があります。 これは規律の要件です。ヘッダーを忘れると 400 が返され、デバッグは高速ですが、初期統合時の小さな問題となります。明確に文書化し、役立つエラーメッセージを検討してください:"x-tenant-id header is required for this endpoint. See docs for details."

ヘッダーはログに表示されます。 テナント ID は秘密情報ではありませんが、識別子です。ログのサニタイズルールが他のメタデータと一貫して扱うようにしてください。

セッション中でのテナント切り替えはアプリケーションレイヤのロジックです。 API はセッション状態における「現在のテナント」を知りませんし、関心もありません。クライアントは常に API にどのテナントコンテキストを使用するかを伝えます。これは明示的で良いことですが、クライアントがその状態を管理する必要があります。

まとめ

x-tenant-id ヘッダーパターンは派手ではありませんが、効果的です。ルートはシンプルに保たれ、ミドルウェアが一貫してスコープを処理し、単一の JWT が複数のテナントコンテキストで機能します。また、必要に応じてデータベースレベルの分離と自然に組み合わせることができます。

テナントが個人ユーザーではなく組織であるほとんどのマルチテナント API では、サブドメイン方式やパス方式に頼る前にこのパターンを検討する価値があります。