Multi-tenancy and Security
Day 1Tenant Isolation Strategies
The isolation spectrum. Shared tables with a tenant_id column, then a schema per tenant, a database per tenant, dedicated infrastructure and separate cloud accounts. Each step adds isolation and cost; no tenant identifier at all is never acceptable.
Tenant from the token. The verified JWT claim is authoritative; a subdomain or X-Tenant-ID header is only a hint and a mismatch is rejected. The tenant lives in a ContextVar, so concurrent requests can't see each other's tenant.
Fail loudly without a tenant. get_current_tenant() raises TenantContextError when no context is set. Any fallback, a default tenant or the previous request's, is exactly how a query ends up without its tenant filter.
RLS as defense in depth. Application repositories add tenant_id to every query; PostgreSQL RLS filters again in the database. FORCE makes policies apply to the table owner too, and app.current_tenant is cleared before a pooled connection is released.
Context must travel. Events carry tenant_id (also the partition key) and jobs take it as an explicit argument, because a ContextVar doesn't reach another process. Cache keys, file paths, search indexes, logs and metrics all include the tenant.
| Shared tables | Schema per tenant | Database per tenant | |
|---|---|---|---|
| Isolation | Application | Database | Physical |
| Backup | All or nothing | Per schema | Per tenant |
| Data residency | Difficult | Possible | Easy |
| Enterprise ready | Rarely | Sometimes | Yes |
- A query without
WHERE tenant_id = ?, returning every tenant's rows. - Trusting a client-supplied
X-Tenant-ID, so an attacker picks the victim's tenant. - Cache keys like
user:{user_id}without a tenant prefix: IDs repeat across tenants.
Day 2Noisy Neighbor Prevention
One tenant breaks everyone. One "export all data" report scanned 2.3 billion rows for 847 seconds: API latency went from 200ms to 15,000ms and 500 other tenants couldn't use the product. Legitimate use, no limits.
Rate limits aren't enough. Rate limiting stops request volume, not expensive operations: five requests a minute can still scan billions of rows. Add concurrency, compute and volume quotas, and enforce at the gateway, application, database and infrastructure.
Limits shared across servers. A token bucket refills up to burst_size: short bursts pass, the long-run rate holds. Per-server limiters give a tenant N times its limit, so one atomic Lua script updates a Redis sliding-window log.
Guard the database. Concurrency slots expire after their TTL, so a crashed worker can't leak one forever. EXPLAIN-based analysis rejects queries over 10 million estimated rows unless the plan is Enterprise; per-tenant pools set statement timeouts.
Schedule fairly. Round-robin across per-tenant queues: a tenant with 100 queued jobs can't make another wait for all of them, and each queue is capped. Weighted fair queuing (free 1, pro 5, enterprise 20) gives paying tiers a larger share.
| Blocks | Example | |
|---|---|---|
| API gateway | Request volume | Per-tenant rate limit |
| Application | Expensive operations | Concurrency, query analysis |
| Database | Runaway statements | Timeouts, per-tenant pools |
| Infrastructure | Resource exhaustion | CPU and memory quotas |
- One global limiter: a tenant at 900 req/sec leaves 100 for everyone else.
- Throttling silently by sleeping, which hides the limit and holds connections; return 429 with Retry-After.
- One limit for every operation: 1,000 exports melt the database.
Day 3Data Residency and GDPR
Four words to keep apart. Residency is where data is stored; sovereignty is which country's laws apply; localization is a legal requirement to keep data in-country; a transfer is moving data across borders, which needs a legal basis.
What counts as personal data. Names, emails, IP addresses, cookie and device IDs, location and behaviour. Pseudonymized data is still personal data, and backups and caches follow the same rules as the primary data.
Regional data, global control plane. The common SaaS pattern: each tenant is assigned a region at onboarding, and its database, storage and search live there. The global control plane holds only metadata: tenant registry, configuration, routing rules.
Consent done right. Freely given, specific per purpose, informed, unambiguous, and as easy to withdraw as to give. Keep an append-only log (exact text, version, IP, time) plus a current-status table; never asked counts as not granted.
Transfers need a basis. Adequacy decision first, then Standard Contractual Clauses with supplementary measures, or BCRs for corporate groups. Post-Schrems II, document a Transfer Impact Assessment and review it, because adequacy can be revoked.
| Single region | Regional deployments | Regional data, global control | |
|---|---|---|---|
| Compliance | Can't meet residency | Strong | Strong |
| Cross-region features | All | None | Some |
| Fits | Non-regulated data | Strict localization | Most SaaS |
- Keeping EU data in Frankfurt but sending backups to a US bucket.
- Logging emails or user IDs to a global logging service.
- Using US vendors without a DPA and a transfer mechanism, or assuming adequacy is permanent.
Day 4Right to Deletion
What Article 17 asks. Respond within one month (two more for complex cases), delete every copy including backups eventually, and have processors delete too. You may refuse for legal obligations, public interest or legal claims, and must explain why.
Deleting is not marking. Soft delete leaves the data in storage and backups, so it's only a step before hard delete. Pseudonymized data is still personal; truly anonymous aggregates ("users made 10,000 orders") may be kept.
Inventory, then orchestrate. Map every place personal data lives, then run one executor per system in dependency order: search, files, analytics, third parties, the primary database (it holds the user_id mapping and foreign keys), and caches last.
Stop, retry, verify. Targets at the same priority run in parallel; after a failed group the run stops and a retry resumes at that point. The request completes only after every system is re-checked and the data is confirmed gone.
Keep what the law requires. Orders are financial records, so they're anonymized (user_id NULL, addresses 'DELETED'), not deleted. Shared content shows "Deleted User". The deletion audit trail is kept, with the user ID redacted, as proof.
Backups expire, restores re-delete. You can't remove one user from a backup, so backups expire under a retention policy that's tracked. If a backup is restored first, the pending deletions are re-applied before the data goes live.
| Hard delete | Anonymize | Pseudonymize | |
|---|---|---|---|
| Data afterwards | Gone | Can't identify anyone | Still personal data |
| Satisfies erasure | Yes | Yes, if truly anonymous | No |
| Use for | Most personal data | Financial records, stats | Protecting active data |
- Deleting the users row and forgetting search, analytics, caches and third parties.
- Reporting "deleted" without a verification pass over every system.
- Deleting audit logs with the user, leaving no proof of what happened.
Day 5Security Architecture
Defense in depth. Five layers, each assuming the others might fail: perimeter (WAF, DDoS protection, rate limits), network (VPC, private subnets), application (authn, authz, validation), data (encryption, keys), monitoring (audit logs, anomaly detection).
Zero trust. Never trust, always verify: network location grants nothing, every connection is authenticated and every request authorized. Least privilege, assume breach, micro-segmentation. Google built BeyondCorp after Operation Aurora.
Secrets never in code. Not in code, config files, container images or environment variables. Fetch from Vault or Secrets Manager at connection time so rotation needs no restart. A leaked secret is already copied everywhere: rotate it.
Encrypt at every layer. TLS 1.2+ externally and mTLS between services; TDE and SSE-KMS at rest; field-level encryption for SSNs and cards, because TDE decrypts for any query. A key hierarchy (HSM master, tenant keys, data keys) limits blast radius.
Revocable tokens. 15-minute access JWTs and 7-day refresh tokens, plus a server-side session checked on every request, so deleting the session revokes access at once. Five failed logins mean a 15-minute lockout; errors stay generic.
| Threat | Mitigation | |
|---|---|---|
| Spoofing | Pretending to be someone | MFA, certificates |
| Tampering | Modifying data or code | Validation, signing |
| Repudiation | Denying an action | Audit logs |
| Disclosure, DoS, privilege | Leaks, outages, escalation | Encryption, rate limits, least privilege |
- Trusting frontend validation:
amount=-500sent straight to the API adds money. - Fetching documents by ID alone, without
AND tenant_id = ?from the authenticated user. allow_origins=["*"]with credentials, or logging passwords, card numbers and auth headers.
BossDesign a Multi-Tenant Enterprise SaaS Platform
Size it first. ~100M documents at 500KB is 50TB; with metadata 60TB, 180TB with 3x replication. Extracted text of 10KB each is a 1TB index, ~3TB in Elasticsearch. 1,000 requests a second at peak, 60% of them searches.
Regional data planes, global control plane. US, EU and APAC each run a full stack: API, PostgreSQL, Elasticsearch, S3, Redis, Kafka. The global control plane holds only the tenant registry, flags, quotas and routing. No tenant data crosses regions.
Five isolation layers. Tenant from the verified JWT (the subdomain must match); repositories that add tenant_id; RLS in PostgreSQL; a search index per enterprise tenant or a filtered shared index; S3 paths and IAM by tenant, with per-tenant keys.
Quotas and fair queues. Per-plan quotas on API calls, searches, uploads and query timeouts. Each tenant gets its own capped job queue; dividing the plan weight by (1 + depth * 0.1) shrinks a deep queue's share.
Uploads go straight to S3. The upload service issues a presigned URL for the tenant's regional bucket, so bytes skip the API servers; text extraction and thumbnails run asynchronously from a queue. The server picks the bucket, never the client.
| Standard | Enterprise | |
|---|---|---|
| Database | Shared tables with RLS | Dedicated database |
| Search | Shared index, tenant_id filter | Index per tenant |
| Isolation level | SHARED (RLS) | DATABASE, own KMS key |
| Index corruption hits | Every tenant on the index | One tenant |
- Reading
tenant_idfrom the request body so admins can "switch tenants". - Putting user emails in the global control plane for faster login routing.
- Deleting the primary database first, leaving search, file and analytics copies you can no longer find.