在构建多租户 SaaS 时,第一个架构问题通常是:如何隔离租户数据?选项范围从每个租户单独的数据库(最大隔离,最高成本)到共享数据库配合行级过滤(最低成本,需要更仔细的编码)。

但有一个同样重要的问题却较少受到关注:您的 API 如何知道请求属于哪个租户上下文?

本文介绍我们在生产环境中使用的一种模式:使用自定义请求头进行租户作用域划分,结合 JWT 身份验证。实现简单,易于审计,并且足够灵活以支持单个用户账户的多租户访问。

三种常见方法

1. 基于子域(tenant.yourdomain.com

租户编码在主机名中。每个子域路由到相同的后端,后端从 Host 头中提取租户。

优点:直观,在 URL 中可见。
缺点:需要通配符 TLS 证书,更复杂的 DNS 设置,开发中不便,对移动 API 客户端不适用。

2. 基于 URL 路径(/api/tenants/{tenantId}/...

租户标识符是每个路由路径的一部分。

优点:RESTful,自文档化。
缺点:使所有路由定义膨胀,需要每个端点包含租户段,使 API 版本控制更复杂。

3. 基于请求头(x-tenant-id: <id>

自定义请求头携带租户上下文。路由保持简洁。租户作用域在处理程序运行前由中间件解析。

优点:路由保持简单,中间件统一处理作用域,与 JWT 身份验证配合良好,易于测试。
缺点:可见性较差(租户不在 URL 中),要求客户端始终包含该请求头。

我们使用请求头方法。

实现

API 接受两种形式的身份验证:

  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

中间件在路由器级别应用,而不是每个处理程序。新路由在租户作用域前缀下自动继承上下文,无需额外工作。

单个账户的多租户访问

请求头模式使另一件事变得容易:单个用户账户访问多个租户。

超级管理员或管理工具需要跨租户查询或切换上下文而无需重新身份验证。使用请求头模式这很简单 — 为用户颁发一个 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 的行级安全可以在应用代码中的 bug 省略 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

在每个请求开始时,在数据库连接上设置当前租户:

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,在使用子域或基于路径的替代方案之前,值得考虑这种模式。