최근 발생한 여러 개인정보 유출 사고를 보면, 공격 수법이 매우 정교해서만 발생하는 것은 아닙니다.
인증 자체는 정상적으로 완료되었으나, 인증된 사용자가 자신에게 허용된 고객사(Tenant)의 범위를 넘어 다른 고객사의 데이터까지 접근할 수 있었던 구조적 결함이 원인이 되곤 합니다.
특히 B2B SaaS의 Admin/Operations Console이나 운영자용 시스템에서는 이러한 문제가 더 치명적일 수 있습니다.
B2B 서비스에서는 단순히 "누가 로그인했는가"를 확인하는 것만으로는 부족합니다. 보안 시스템은 항상 다음 두 가지를 함께 검증해야 합니다.
이 사용자는 누구인가?
이 사용자는 어느 Tenant의 어떤 Resource에 접근할 수 있는가?
즉, B2B SaaS에서 Tenant는 단순한 데이터 구분용 값이 아니라 보안 경계(Security Boundary)로 다루어야 합니다.
1. Authentication만으로는 B2B SaaS를 보호할 수 없다
일반적인 인증 시스템은 다음과 같은 정보를 토큰에 포함합니다.
{
"sub": "user-123",
"tenant_id": "customer-a",
"roles": ["admin"]
}
JWT에 tenant_id가 포함되어 있으니 안전하다고 생각할 수 있습니다.
그러나 실제 문제는 인증 이후에 발생합니다.
예를 들어 다음과 같은 API를 살펴보겠습니다.
@Get('/users/:userId')
getUser(@Param('userId') userId: string) {
return this.userRepository.findOne({
where: { id: userId }
});
}
사용자가 정상적으로 로그인하고 유효한 JWT를 가지고 있더라도, 위 코드에서는 요청자의 Tenant와 조회 대상 사용자의 Tenant를 검증하지 않습니다.
따라서 핵심 문제는 Authentication이 아니라 Authorization과 Data Isolation입니다.
안전한 시스템이라면 실제 조회 조건에는 항상 Tenant가 포함되어야 합니다.
findOne({
where: {
id: userId,
tenantId: currentTenant.id
}
});
하지만 여기에도 문제가 있습니다.
개발자가 모든 API에서 누락 없이 tenantId를 조회 조건에 넣을 것이라 기대하는 것은 현실적인 보안 대책이 아닙니다.
결국 중요한 설계 원칙은 다음과 같습니다.
개발자가 매번 Tenant 조건을 챙겨야 하는 방식이 아니라, Tenant 조건이 없으면 데이터 접근 자체가 불가능한 구조를 설계해야 합니다.
2. Tenant를 Security Context의 일부로 만든다
B2B SaaS의 모든 요청에는 다음과 같은 Security Context를 구성하는 것이 좋습니다.
interface SecurityContext {
userId: string;
tenantId: string;
roles: string[];
permissions: string[];
clientId?: string;
authType:
| 'user'
| 'api-key'
| 'oauth-client';
}
요청 처리 흐름 역시 명확해야 합니다.
Request
│
▼
Authentication
│
▼
Tenant Context Resolution
│
▼
Security Context
│
▼
Authorization Policy
│
▼
Application Service
│
▼
Repository
여기서 매우 중요한 원칙이 하나 있습니다.
클라이언트가 전달한 Tenant ID를 그대로 신뢰해서는 안 됩니다.
예를 들어 다음과 같은 요청은 위험합니다.
Authorization: Bearer <token>
X-Tenant-ID: customer-b
Tenant는 HTTP 헤더나 요청 파라미터로 전달받아 결정하는 것이 아니라,
JWT
OAuth Client
API Key
Session
등 이미 검증된 인증 정보(Credential)로부터 안전하게 추출해야 합니다.
즉,
Credential
↓
Identity
↓
Tenant
방식으로 유도되어야 합니다.
반대로
Request
↓
Tenant 선택
방식으로 흐르면 위험합니다.
3. API Key 역시 Tenant에 귀속되어야 한다
B2B 서비스에서는 API Key를 제공하는 경우가 많습니다.
이때 API Key를 단순한 인증용 문자열로 다루면 보안상 허점이 생길 수 있습니다.
API KEY
↓
API 접근 가능
API Key 자체가 하나의 독립된 주체(Security Principal)로서 역할을 해야 합니다.
예를 들어 다음과 같은 메타데이터를 관리할 수 있습니다.
API Key
├─ key_id
├─ tenant_id
├─ application_id
├─ scopes
├─ environment
├─ allowed_resources
├─ expiration
└─ status
예를 들면:
{
"key_id": "key_82374",
"tenant_id": "tenant_abc",
"scopes": [
"invoice.read",
"invoice.write"
],
"environment": "production"
}
이 구조에서는 API Key가 곧 Tenant를 결정합니다.
따라서 다음과 같이 요청받는 형태는 피해야 합니다.
API Key
+
Caller가 전달한 Tenant ID
대신:
API Key
↓
Credential Store
↓
Tenant
↓
Scope
구조가 되어야 합니다.
4. OAuth를 사용한다고 해서 내부 API까지 같은 Token을 사용할 필요는 없다
외부 고객에게 OAuth를 제공하는 경우, 다음과 같은 구조를 고려할 수 있습니다.
Customer
│
▼
OAuth Authorization Server
│
▼
OAuth Access Token
│
▼
Token Exchange / Internal Authorization
│
▼
Internal JWT
내부 JWT에는 단순 User ID뿐만 아니라 보안 검증에 필요한 Context를 함께 담아 전달합니다.
{
"iss": "auth.example.com",
"aud": "admin-operations-api",
"sub": "user-123",
"tenant": "tenant-abc",
"client_id": "client-xyz",
"roles": [
"billing-admin"
],
"permissions": [
"billing.invoice.read"
],
"auth_level": "mfa",
"jti": "token-id"
}
이 중에서도 특히 주의 깊게 다뤄야 하는 속성이 바로 aud(Audience)입니다.
토큰 검증 시에는 단순히 토큰이 유효한지뿐만 아니라,
이 Token이 이 API를 위해 발급된 Token인가?
도 확인해야 합니다.
예를 들어 다음처럼 API 별 Audience를 분리할 수 있습니다.
aud = customer-api
aud = partner-api
aud = admin-operations-api
aud = admin-api
aud = internal-api
이렇게 Audience를 엄격히 분리하면, 고객 포털용 토큰을 어드민/운영 API 요청에 오용하는 것과 같은 토큰 혼동(Token Confusion / Confused Deputy) 문제를 예방할 수 있습니다.
5. Admin/Operations Console은 별도의 Trust Domain으로 보는 것이 좋다
B2B SaaS에서 Admin/Operations Console은 일반 고객용 API보다 막강한 권한을 가지는 경우가 많습니다.
따라서 단순히 URL 경로만 분리하는 것만으로는 부족합니다.
/customer/*
/admin/*
/admin-console/*
보다 중요한 것은 Security Domain 자체를 분리하는 것입니다.
예를 들어:
Identity Provider
│
┌──────────────┴──────────────┐
│ │
▼ ▼
Customer Security Domain Admin/Operations Domain
│ │
Customer JWT Employee Identity
Customer API Key MFA
Partner OAuth Device Policy
│ Network Policy
▼ │
Customer API ▼
Admin/Operations API
고객용 토큰과 어드민/운영 콘솔용 토큰이 서로 교차 사용되지 않도록 완전히 차단해야 합니다.
이를 위해 다음을 분리할 수 있습니다.
Issuer
Audience
Client
Scope
Authentication Requirement
Token Lifetime
Admin/Operations 콘솔 환경에는 고객용 API보다 훨씬 짧은 토큰 만료 시간과 MFA 필수 적용 등의 엄격한 정책을 반영하는 것이 좋습니다.
6. RBAC만으로는 Multi-Tenant Authorization이 충분하지 않다
많은 시스템이 Role 기반으로 권한을 관리합니다.
예를 들면:
CustomerAdmin
CustomerUser
SupportAgent
SystemAdmin
그러나 Role만으로는 Tenant Isolation을 표현하기 어렵습니다.
예를 들어 다음 조건만 있다면:
role == CustomerAdmin
고객사 A의 관리자가 고객사 B의 데이터에 접근하는 것을 막지 못합니다.
따라서 B2B SaaS 환경에서는 RBAC와 ABAC를 함께 적용해야 합니다.
Role == CustomerAdmin
AND
Principal.Tenant == Resource.Tenant
조금 더 일반화하면:
ALLOW IF
subject.tenant_id == resource.tenant_id
AND
subject.permissions contains required_permission
AND
subject.environment == resource.environment
가 됩니다.
즉,
RBAC
+
ABAC
구조입니다.
Role은 사용자의 기능적 권한을 정의하고, Attribute는 Tenant, Environment, Resource Ownership 같은 문맥(Context)을 제어합니다.
7. Authorization을 Controller의 if 문으로 구현하지 않는다
인가 처리가 비즈니스 로직 중간중간에 다음과 같이 파편화되어 있으면 관리하기가 매우 어렵습니다.
if (
user.tenantId === resource.tenantId &&
user.roles.includes('admin')
) {
...
}
따라서 권한 검증을 담당하는 정책 레이어(Policy Layer)를 분리하는 것이 바람직합니다.
예를 들어 NestJS에서는 다음과 같은 형태를 사용할 수 있습니다.
@RequirePermission('customer.user.read')
@Get(':id')
async getUser() {}
실제 정책 판단은 별도의 Policy Engine에서 수행합니다.
policy.can({
subject,
action: 'read',
resource,
tenant
});
이렇게 하면 Controller는
무엇을 수행하는가
에 집중하고,
Policy Layer는
누가 무엇에 접근할 수 있는가
를 담당하게 됩니다.
8. 언어나 프레임워크보다 Policy Enforcement 구조가 중요하다
Spring Boot에는 Spring Security가 있고, ASP.NET Core에도 강력한 Authentication / Authorization Framework가 있습니다.
NestJS 역시 Guard, Decorator, Interceptor 등을 제공하지만, 다중 테넌트 환경에 맞는 인가 구조가 자동으로 완성되는 것은 아닙니다.
따라서 Node.js/NestJS 환경에서는 인가 구조를 체계적으로 설계하는 데 신경 써야 합니다.
시스템 규모가 커지면 인가 정책(Authorization Policy)을 애플리케이션 외부로 격리하는 아키텍처도 좋은 대안이 됩니다.
자주 활용되는 대표적인 정책 엔진(Policy Engine)은 다음과 같습니다.
Open Policy Agent
Cedar
Cerbos
Casbin
구조적으로는 다음과 같습니다.
Application
│
▼
Policy Decision Point
│
┌──┴──┐
│ │
DENY ALLOW
이 방식의 장점은 여러 언어나 프레임워크가 혼재된 환경에서도 통합된 정책 모델을 적용할 수 있다는 점입니다.
예를 들어 조직 내에
Java / Spring
.NET
Node.js / NestJS
Python
Go
독립적으로 동작하더라도 일관된 보안 규칙을 유지할 수 있습니다.
결국 문제를
NestJS는 보안이 약하다
라는 특정 프레임워크의 한계로 접근하기보다는,
Authorization Policy와 Enforcement Point가 일관되게 설계되어 있는가
라는 아키텍처 관점에서 다루는 것이 본질에 가깝습니다.
9. 가장 중요한 마지막 방어선: Database Tenant Isolation
애플리케이션 레이어의 인가 검사만으로는 완벽하지 않을 수 있습니다.
개발자의 실수로 리포지토리 쿼리에 테넌트 조건이 누락될 가능성이 언제든 존재하기 때문입니다.
예를 들어 다음과 같은 쿼리가 실행되었다고 가정해 봅시다.
SELECT *
FROM customer_data;
애플리케이션 레벨에서만 격리를 담당하고 있다면, 순간의 실수로 모든 테넌트의 데이터가 그대로 노출될 위험이 있습니다.
PostgreSQL을 사용하는 경우 행 수준 보안(Row Level Security, RLS)을 최종 방어선으로 구축할 수 있습니다.
예:
CREATE POLICY tenant_isolation
ON customer_data
USING (
tenant_id = current_setting('app.tenant_id')
);
요청 및 트랜잭션의 Tenant Context를 DB 세션에 전달하면,
SET LOCAL app.tenant_id = 'tenant-abc';
애플리케이션 코드에서 실수로 Tenant 조건을 누락하더라도 DB 차원에서 데이터 접근을 자동으로 제한해 줍니다.
결과적으로:
Application Authorization
+
Database Authorization
형태의 촘촘한 심층 방어(Defense in Depth) 체계가 완성됩니다.
10. Repository 자체를 Tenant-aware하게 만든다
또 하나의 방법은 일반 Repository 접근을 최소화하는 것입니다.
예를 들어 다음과 같이 공용 메서드를
userRepository.find();
직접 사용하지 않고,
tenantUserRepository.find(tenantContext);
또는:
const tenantDb =
db.forTenant(securityContext.tenantId);
와 같이 이미 Tenant가 컨텍스트에 바인딩된 전용 Repository를 활용하는 것입니다.
이렇게 하면 비즈니스 로직 작성 시:
tenantDb.users.findMany();
tenantDb.orders.findMany();
tenantDb.invoices.findMany();
처럼 동작하게 합니다.
이 방식의 핵심은 매우 단순합니다.
테넌트 필터링을 누락하지 않고 챙겨야 하는 부담을 개별 개발자에게 맡기지 않는다.
11. Admin/Operations Console의 Tenant Switching은 Impersonation으로 다루어야 한다
운영자용 Admin/Operations Console에는 흔히 다음과 같은 기능이 존재합니다.
Current Customer
[ Customer A ▼ ]
운영자가 여러 고객사를 조회할 수 있다는 것 자체는 필요할 수 있습니다.
문제는 이를 단순히 요청 파라미터의 Tenant ID 변경으로 구현했을 때입니다.
안전한 접근 방식은 이러한 테넌트 전환을 명확한 Impersonation(대리 수행) 세션으로 격리하여 관리하는 것입니다.
예:
{
"employee": "operator-123",
"acting_tenant": "tenant-abc",
"reason": "support-ticket-28374",
"session_id": "impersonation-123"
}
그리고 다음과 같이 명확한 감사 로그(Audit Trail)를 남겨야 합니다.
10:23:02
Operator A
Started impersonation
Tenant: Customer B
Reason: Support Ticket #23871
10:23:17
Viewed Customer Profile
10:24:01
Downloaded Billing Record
10:25:14
Ended impersonation
이렇게 처리하면 Tenant Switching이 단순 편의 기능이 아니라 감사 대상이 되는 주요 보안 이벤트가 됩니다.
12. Export와 Download는 일반 Read 권한과 구분해야 한다
개인정보 유출 사고 시 대규모 피해로 이어지기 쉬운 취약한 API들이 있습니다.
/search
/list
/export
/download
그중에서도 데이터 대량 Export 기능은 특히 주의해야 합니다.
따라서 단순 조회 권한과 대량 내보내기 권한은 엄격히 분리해야 합니다.
customer.read
customer.export
Export 권한에는 다음과 같은 강화된 보안 요소를 조합해 적용합니다.
Tenant Scope
+
Dedicated Permission
+
MFA
+
Row Limit
+
Rate Limit
+
Audit Logging
+
Anomaly Detection
예를 들어 동일한 계정이 단시간에 여러 테넌트의 데이터를 대량 다운로드하려 할 경우, 즉시 이상 징후로 판단하여 차단 및 알림 조치를 취할 수 있습니다.
13. API Key의 Blast Radius도 줄여야 한다
고객사마다 모든 권한을 가진 단일 마스터 API Key를 제공하는 형태 또한 위험 요인이 될 수 있습니다.
Customer API Key
│
▼
모든 기능 접근
따라서 API Key 역시 목적과 용도에 맞춰 최소 권한으로 세분화하여 발급하는 것이 바람직합니다.
Billing API Key
Customer Read API Key
Webhook API Key
Upload API Key
그리고 각각:
Credential
├─ Tenant
├─ Application
├─ Environment
├─ Scopes
└─ Expiration
에 Binding합니다.
하나의 인증키가 유출되더라도 전체 시스템에 미치는 피해 범위를 최소화하는 것이 목적입니다.
14. 권장하는 전체 Architecture
B2B SaaS의 Authorization Architecture를 하나의 흐름으로 표현하면 다음과 같습니다.
OAuth / Identity Provider
│
▼
Token Exchange
│
▼
Internal JWT
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
Identity Tenant Audience
Resolver Resolver Validator
│ │ │
└────────────────┼────────────────┘
│
▼
Security Context
│
▼
Policy Engine
RBAC + ABAC
│
┌─────────┴─────────┐
│ │
DENY ALLOW
│
▼
Tenant Service
│
▼
Tenant Repository
│
▼
Database RLS
이 구조에서는 Tenant Isolation이 단 한곳에서만 수행되지 않습니다.
Credential
│
▼
Authorization Policy
│
▼
Repository
│
▼
Database
여러 계층에서 반복적으로 검증됩니다.
이것이 진정한 의미의 심층 방어(Defense in Depth)입니다.
15. 그리고 반드시 자동화된 Cross-Tenant Test를 추가한다
아무리 뛰어난 보안 아키텍처를 설계하더라도 기능 변경 중 회귀 오류(Regression)는 언제든 일어날 수 있습니다.
따라서 멀티 테넌트 SaaS 환경에서는 일반적인 단위 테스트와 별개로 테넌트 간 격리 검증 테스트(Cross-Tenant Authorization Test)를 필수 테스트 슈트로 구축하는 것이 좋습니다.
예를 들어:
Tenant A Credential
│
▼
Tenant B Resource
│
▼
Expected: DENY
다음과 같은 Principal Matrix를 만들 수 있습니다.
Tenant A User
Tenant A Admin
Tenant A API Key
Tenant B User
Tenant B Admin
Tenant B API Key
Admin/Operations Operator
System Administrator
이를 주요 대상 리소스와 조합하여 검증합니다.
User
Order
Invoice
Payment
Document
Export
API Credential
결과적으로 다음과 같은 다차원 매트릭스 형태로 자동화 검증을 지속 수행하게 됩니다.
Principal
×
Tenant
×
Resource
×
Action
예를 들어 CI/CD에서 다음 조건을 검사할 수 있습니다.
Tenant A Credential
→ Tenant B Resource
GET → 403
PUT → 403
DELETE → 403
EXPORT → 403
만약 단 한 건이라도 타 테넌트의 데이터에 접근할 수 있는 경로가 발견되면, 배포 파이프라인(CI/CD)을 즉시 중단하도록 설정합니다.
이러한 테스트는 단순 기능 검증을 넘어,
Tenant Isolation이라는 아키텍처 핵심 불변성(Architecture Invariant)을 지속해서 수호하는 안전장치
로 바라보는 것이 적절합니다.
Conclusion
Multi-Tenant B2B SaaS에서 가장 위험한 가정 중 하나는 다음과 같습니다.
인증을 거친 정식 사용자이므로 안전할 것이다.
실제로 필요한 판단은 훨씬 더 구체적입니다.
이 Principal이
이 Credential을 사용해서
이 Tenant에서
이 Resource에
이 Action을 수행할 권한이 있는가?
따라서 B2B SaaS의 보안 모델은 단순 Authentication 중심에서 다음 구조로 확장되어야 합니다.
Identity
+
Tenant
+
Credential
+
Resource
+
Action
+
Context
또한 이 정책을 단 하나의 레이어에서만 검증해서는 안 됩니다.
가장 현실적인 접근은 다음과 같은 다층 구조입니다.
Tenant-bound Credential
↓
Security Context
↓
RBAC + ABAC Policy
↓
Tenant-aware Repository
↓
Database Isolation
↓
Audit & Detection
Spring Boot, ASP.NET Core, NestJS 등 어떤 프레임워크를 선택했는지도 의미가 있지만,
더 본질적인 질문은 이것입니다.
Framework가 무엇이든 Tenant Isolation을 우회할 수 없는 구조를 만들었는가?
B2B SaaS에서 Tenant는 단순히 DB 테이블에 있는 tenant_id 컬럼 하나만을 의미하지 않습니다.
Tenant는 시스템의 최우선 보안 경계이며, 전체 아키텍처가 이 경계를 강제하도록 설계되어야 합니다.