When the system involves a multi-tenant architecture, prevent cross-tenant data privilege escalation.
Pitfall #1: Incorrect trust in the source of tenant context
Scenario: The interceptor/filter sets the tenant context from a request header (such as X-Tenant-Code), but does not perform a consistency check against the tenantId in the authentication token
Root cause
Request headers can be arbitrarily forged by the client. If the backend only trusts the tenant identifier in the request header, an attacker only needs to modify the header to access another tenant's data.
Incorrect example
// ❌ 错误: 只信任请求头,未校验 token
@Override
public boolean preHandle(HttpServletRequest request, ...) {
String tenantCode = request.getHeader("X-Tenant-Code");
TenantMiniAppConfig config = configRepository.findByTenantCode(tenantCode);
TenantContext.setTenantId(config.getTenantId()); // 直接信任 header
return true;
}
// 攻击者拿着 tenantId=1 的 token,配上 X-Tenant-Code: OTHER_TENANT
// 就能读到其他租户的数据
Correct approach
// ✅ 正确: header 只做路由定位,必须与 token tenantId 校验一致
@Override
public boolean preHandle(HttpServletRequest request, ...) {
String tenantCode = request.getHeader("X-Tenant-Code");
TenantMiniAppConfig config = configRepository.findByTenantCode(tenantCode);
// 从认证 token 中取出 tenantId(真相源)
Long tokenTenantId = (Long) request.getAttribute("tokenTenantId");
if (tokenTenantId != null && !tokenTenantId.equals(config.getTenantId())) {
response.setStatus(403);
response.getWriter().write("{\"code\":403,\"message\":\"租户信息不匹配\"}");
return false;
}
TenantContext.setTenantId(config.getTenantId());
return true;
}
Checklist
- Is the ultimate source of tenant context based on the authentication token
- Is the tenant identifier in the request header used only for routing/location, rather than trusted directly
- Is a consistency check performed between the tenantId in the token and the tenant in the request header
- When validation fails, is 403 returned instead of silently allowing the request
Pitfall #1.5: Service-layer call to Repository.findById(id) missing tenant filtering
Parallel to Pitfall #2: Pitfall #2 is "global filtering as a fallback", while this pitfall is "explicit defense in depth". Even if the project already uses Hibernate
@Filter/ MyBatis interceptor, it is still recommended that the Service layer explicitly callfindByTenantIdAndId—direct lookup by primary key often bypasses global filtering on paths such as second-level cache hits,getReferenceById. Using both layers together also improves readability (looking at the Service code, you can tell that tenantId is isolated).
Scenario: Even with a global filtering mechanism, developers directly calling repo.findById(id) in the Service can still bypass filtering—primary key queries are often treated by JPA/Hibernate as "direct lookup by ID", skipping the entity filter
Root cause
- JPA second-level cache /
findByIduses the persistence context, skipping@Filter - An attacker who obtains any ID (order numbers, user IDs may be enumerable from other channels) can read across tenants
- A single leak point is enough to leak data: during N+1 optimization,
for (X x : list) { ... repo.findById(x.foreignKey) ... }often appears, each time missing tenantId
Incorrect example
// ❌ 错误:直接按主键查
WxUser buyer = wxUserRepository.findById(buyerUserId).orElse(null);
// ❌ 错误:N+1 修复时也漏掉
List<User> users = userIds.stream()
.map(id -> userRepository.findById(id).orElse(null)) // ← 无租户过滤
.toList();
// ❌ 错误:批量 IN 也忘记带 tenantId
List<User> users = userRepository.findAllById(userIds);
Correct approach
The Repository must provide a "primary key query with tenantId" method, and the Service must always call it:
// ✅ Repository 强制提供租户感知方法
public interface WxUserRepository extends JpaRepository<WxUser, Long> {
Optional<WxUser> findByTenantIdAndId(Long tenantId, Long id);
List<WxUser> findByTenantIdAndIdIn(Long tenantId, Collection<Long> ids);
}
// ✅ Service 一律带 tenantId
WxUser buyer = wxUserRepository.findByTenantIdAndId(tenantId, buyerUserId).orElse(null);
// ✅ 批量也带
Map<Long, WxUser> userMap = wxUserRepository
.findByTenantIdAndIdIn(tenantId, userIds)
.stream()
.collect(Collectors.toMap(WxUser::getId, u -> u));
Sniffing signals (grep these during review/audit)
# 1. Service 层任何裸 findById(绝大多数应迁移)
grep -rn "Repository.findById(" src/main/java/**/service/
# 2. JpaRepository 默认方法(这些都"按 ID 直查",绕开 entity filter)
grep -rnE "(findById|getOne|getById|getReferenceById|findAllById)\(" src/main/java/
# 3. 检查是否所有 Repository 都有租户感知主键方法
grep -L "findByTenantIdAndId" src/main/java/**/repository/*Repository.java
Checklist
- The Service layer must prohibit bare
findById/getOne/getReferenceById/findAllById - Every Repository should provide at least
findByTenantIdAndIdandfindByTenantIdAndIdIn - When introducing a new Repository, the primary key method and IN methodmustbe tenant-aware
- CI check (optional): scan
*Service.javainfindById(calls and fail the build - The "super admin" interface of a cross-tenant management backend needs a separate endpoint + explicit comments (cannot reuse business findById)
Pitfall #2: Missing global tenant filtering in the data query layer
Scenario: Some queries bypass tenant filtering, causing cross-tenant data leakage
Root cause
It relies on developers manually adding WHERE tenant_id = ?in every query, which is easy to miss.
Incorrect example
// ❌ 错误: 忘记加租户过滤
@Query("SELECT p FROM Product p WHERE p.categoryId = :categoryId")
List<Product> findByCategoryId(@Param("categoryId") Long categoryId);
// 返回所有租户的商品
Correct approach
// ✅ 方案1: JPA/Hibernate 全局过滤器(推荐)
@Entity
@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = Long.class))
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Product {
private Long tenantId;
}
// ✅ 方案2: 基类强制携带 tenantId
public abstract class TenantAwareEntity {
@Column(name = "tenant_id", nullable = false)
private Long tenantId;
}
// ✅ 方案3: MyBatis 拦截器自动追加 tenant_id 条件
@Intercepts(@Signature(type = Executor.class, method = "query", ...))
public class TenantInterceptor implements Interceptor {
// 自动在 SQL 中追加 AND tenant_id = ?
}
Checklist
- Is there a global tenant filtering mechanism (Hibernate Filter / MyBatis interceptor / base class)
- Are newly added query methods automatically protected by tenant filtering
- Do native SQL / @Query manually add the tenant_id condition
- Do cross-tenant management interfaces (super admin) have an independent bypass mechanism
Pitfall #3: Frontend does not handle tenant mismatch 403
Scenario: The backend returns 403 (tenant mismatch), but the frontend does not handle it correctly, and the user sees a blank page or no prompt
Incorrect example
// ❌ 错误: 只处理 401,忽略 403
request.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
clearAuth();
redirectToLogin();
}
return Promise.reject(error); // 403 被静默吞掉
}
);
Correct approach
// ✅ 正确: 403 租户不匹配时清理登录态并跳转
request.interceptors.response.use(
response => response,
error => {
const status = error.response?.status;
const message = error.response?.data?.message || '';
if (status === 401) {
clearAuth();
redirectToLogin();
} else if (status === 403 && message.includes('租户')) {
clearAuth();
redirectToLogin();
showToast('登录状态异常,请重新登录');
}
return Promise.reject(error);
}
);
Checklist
- Does the frontend handle the 403 status code uniformly
- Does a tenant mismatch 403 clear the login state and redirect to the login page
- Is the user given a clear error prompt (rather than a blank page)
Pitfall #4: Tenant ID input box allows manual input
Scenario: In the admin backend configuration form, the tenant ID uses a manual input box, which is prone to typos
Incorrect example
// ❌ 错误: 手动输入租户 ID,容易输错
<InputNumber placeholder="请输入租户ID" />
Correct approach
// ✅ 正确: 下拉选择租户名称,提交时自动转为 tenantId
<Select
placeholder="请选择租户"
onChange={(value) => {
form.setFieldsValue({ tenantId: value });
const tenant = tenants.find(t => t.id === value);
form.setFieldsValue({ tenantCode: tenant?.tenantCode });
}}
>
{tenants.map(t => (
<Option key={t.id} value={t.id}>
{t.tenantName} / {t.tenantCode}
</Option>
))}
</Select>
Checklist
- Do tenant-related fields in the admin backend use dropdown selection rather than manual input
- Do the dropdown options display the tenant name (rather than only the ID)
- After selecting a tenant, are related fields (such as tenantCode) automatically populated
Pitfall #5: Feature-level authorization ≠ data-level isolation
Scenario: Features opened only to specific tenants/roles/subscription tiers only use isYmhwTenant / hasPermission / isPaid to hide the entry point on the frontend, and the backend endpoint has no independent feature authorization check
Root cause of the problem
Frontend UI control (button hiding, menu filtering) is onlyuser experience optimization, not a security boundary. Any logged-in user who knows the endpoint path can bypass the UI and call the interface directly to use capabilities they should not have. This is a different dimension from "data-layer tenant isolation":
- Data layer (Pitfalls #1/#2): The scope of data accessed (your data vs. others' data)
- Business layer (this pitfall): The features that can be used (what features you can use vs. what features others can use)
Typical scenarios:
- Tenant-exclusive features (such as YMHW mini-program shipping template export, available only to the Yumi Haowu tenant)
- Role-exclusive features (only administrators can batch delete/batch export)
- Subscription/version-exclusive features (paid version AI analysis, enterprise version advanced reports)
- Feature flags (feature flag canary release)
Incorrect example
// ❌ 错误: 后端只过滤当前租户数据,没校验"该租户是否启用该功能"
@PostMapping("/orders/miniapp-shipping-template/export")
public ResponseEntity<byte[]> export(@RequestBody ExportRequest req) {
Long tenantId = TenantContext.getTenantId();
// 只查当前租户的订单(数据隔离 OK),但任何已登录租户都能调用这个接口
return service.export(tenantId, req.getOrderIds());
}
// 前端通过 isYmhwTenant 隐藏入口(只是体验优化,不是安全边界)
{isYmhwTenant && <Button onClick={handleExport}>导出小程序发货模板</Button>}
Correct approach
// ✅ 正确: endpoint 入口处独立校验"该租户是否启用该功能"
@PostMapping("/orders/miniapp-shipping-template/export")
public ResponseEntity<byte[]> export(@RequestBody ExportRequest req) {
Long tenantId = TenantContext.getTenantId();
// 关键: 后端独立校验租户编码/功能开关,不依赖前端
TenantMiniAppConfig config = configRepository.findByTenantId(tenantId)
.orElseThrow(() -> new BusinessException(403, "未启用小程序发货模板导出"));
if (!"YMHW".equalsIgnoreCase(config.getTenantCode())) {
throw new BusinessException(403, "仅鱼米好物租户支持导出小程序发货模板");
}
return service.export(tenantId, req.getOrderIds());
}
Implementation strategy
| Authorization basis | Implementation method | Applicable scenario |
|---|---|---|
| Tenant code whitelist | endpoint entry if check / @RequireTenantCode annotation + AOP |
Single/few tenant-exclusive |
| Role permissions | @PreAuthorize("hasRole('ADMIN')") / Spring Security / Casbin |
Within the RBAC system |
| Subscription status | endpoint entry checks whether the current subscription covers the feature | SaaS by version |
Priority: For 1-2 endpoints, use direct if validation (simplest, highly readable); for the same restriction on 3 or more, then consider AOP/middleware abstraction to avoid over-engineering.
Checklist
- Does the endpoint entry point have an independent check for "whether this user/tenant has this feature enabled" (not relying on frontend hiding)
- Is the authorization basis clear (tenant code / role / feature flag / subscription status)
- On validation failure, does it return 403 + a business-readable reason (not 500 or an empty response)
- Has penetration testing been done for "bypassing the UI and calling the API directly" (using curl/Postman to simulate an unauthorized tenant)
Multilingual Examples
For complete implementation examples in Java (Spring Boot annotation AOP) / Go (Gin middleware) / TypeScript (Express middleware + NestJS Guard), see references/multi-lang-examples.md。
Checklist (Multi-Tenant Isolation)
Authentication and Authorization:
- Is the ultimate source of the tenant context based on the authentication token
- Is the tenant identifier in headers/parameters used only for routing, not trusted
- Is consistency validation performed between the token tenantId and the routed tenant
- On validation failure, does it return 403
Feature Authorization(business layer, see Pitfall #5):
- For features open only to specific tenants/roles/subscription tiers, does the endpoint entry point have an independent check
- Has the anti-pattern of "only hiding the button on the frontend, with no backend validation" been avoided
- On authorization failure, return 403 + a business-readable reason
- Has penetration testing been done for "bypassing the UI and calling the API directly"
Data Isolation:
- Is there a global tenant filtering mechanism
- Are newly added queries automatically protected by tenant filtering
- Has the tenant_id condition been manually added to native SQL
- Are there test cases for cross-tenant data leakage
Frontend Handling:
- Is a 403 tenant mismatch handled correctly
- Do tenant-related configurations use a dropdown selection
- Does the list page display the tenant name instead of the ID
Scope of Application
- Java: Spring Boot + JPA/Hibernate / MyBatis
- Go: Gin + GORM / sqlx
- Node.js: Express + Prisma / TypeORM
- Python: FastAPI + SQLAlchemy
Rule Provenance
> 📋 本回复遵循:`multi-tenant-safety` - [章节名]