The "One Pool Per Tenant" Wall
If you've ever built a multi-tenant SaaS on AWS, you've likely reached for Amazon Cognito. It’s the logical choice: managed, secure, and integrates deeply with the AWS ecosystem. But as your platform grows from 10 to 100 to 500 tenants, you hit a hard, non-negotiable ceiling: The 1,000 User Pool Limit.
For many developers, this is the moment of panic. Do you request a quota increase (which is rarely granted for this specific limit)? Do you migrate to Auth0 and watch your margins disappear? Or do you re-architect?
In this deep dive, we’re going to explore how to break past the 1,000-pool barrier by moving from a "Siloed" identity model to a "Shared" or "Hybrid" architecture. We’ll look at production-ready patterns using Node.js and Python, and how to maintain strict tenant isolation without the infrastructure bloat.
Why the Siloed Model Fails at Scale
The "One User Pool Per Tenant" (Siloed) approach is often the first choice because it offers the cleanest isolation. Each tenant has their own user directory, their own password policies, and their own custom attributes.
The Trade-offs:
- Infrastructure Management: Managing 1,000+ CloudFormation stacks or Terraform resources becomes a nightmare.
- Global Configuration: Want to update a password policy across all tenants? You’re now running 1,000 API calls.
- Cross-Tenant Features: Building a "Global Admin" dashboard that can see users across tenants requires complex custom logic.
- The Hard Limit: AWS enforces a 1,000 user pool limit per account. While you can use multiple accounts, you're just kicking the can down the road.
Pattern 1: The Shared User Pool (Custom Attributes)
The most common way to scale is to move all tenants into a single, massive User Pool. You distinguish tenants using a custom attribute (e.g., custom:tenant_id).
Implementation Strategy
When a user logs in, your application checks their tenant_id claim and ensures they only access data belonging to that ID.
// Example: Verifying Tenant ID in a Lambda Authorizer
import { CognitoJwtVerifier } from "aws-jwt-verify";
const verifier = CognitoJwtVerifier.create({
userPoolId: process.env.USER_POOL_ID!,
tokenUse: "access",
clientId: process.env.CLIENT_ID!,
});
export const handler = async (event: any) => {
try {
const payload = await verifier.verify(event.authorizationToken);
const tenantId = payload["custom:tenant_id"];
if (!tenantId) {
throw new Error("Missing tenant context");
}
return {
principalId: payload.sub,
policyDocument: generatePolicy(payload.sub, "Allow", event.methodArn),
context: { tenantId }, // Pass to downstream services
};
} catch (err) {
return "Unauthorized";
}
};
The Catch: Customization
The Shared model works perfectly until Tenant A wants "Sign in with Google" and Tenant B wants "Sign in with Microsoft," or Tenant C requires 16-character passwords while everyone else uses 8.
Pattern 2: The Hybrid Model (App Clients & Identity Providers)
To solve the customization problem without hitting the 1,000 pool limit, we use App Clients and Identity Providers (IdPs) within a single pool.
Cognito allows up to 1,000 App Clients per User Pool. Each tenant gets their own App Client ID. You can then associate specific IdPs (SAML, OIDC, Social) with specific App Clients.
The Workflow:
- Tenant Onboarding: Create a new App Client for the tenant.
-
Domain Mapping: Map the tenant's subdomain (e.g.,
tenant-a.myapp.com) to their specificclient_id. -
Login Flow: Your frontend uses the
client_idto initiate the login. Cognito handles the specific IdP routing for that client.
Pattern 3: The "Cell-Based" Identity Architecture
For enterprise-grade SaaS with tens of thousands of tenants, even 1,000 App Clients won't cut it. This is where we move to a Cell-Based Architecture.
You group tenants into "Cells." Each Cell is a standalone unit of infrastructure containing one User Pool.
- Cell 1: Tenants 1-900
- Cell 2: Tenants 901-1800
- Cell 3: Tenants 1801-2700
The Router Pattern
You need a "Global Router" (usually a DynamoDB table + Lambda) that maps a tenant_id or email_domain to a specific UserPoolId and ClientId.
# Example: Identity Router in Python
import boto3
import os
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['TENANT_ROUTING_TABLE'])
def get_tenant_config(tenant_slug):
response = table.get_item(Key={'slug': tenant_slug})
item = response.get('Item')
if (!item):
raise Exception("Tenant not found")
return {
'user_pool_id': item['user_pool_id'],
'client_id': item['client_id'],
'region': item['region']
}
Security Considerations: Preventing Tenant Leaks
In a shared identity model, the biggest risk is Cross-Tenant Data Access. If a user from Tenant A can manually change their tenant_id in a request and see Tenant B's data, your SaaS is dead.
1. JWT Claims are Immutable
Never trust a tenant_id passed in a request body or header. Always extract it from the verified JWT claims.
2. Row-Level Security (RLS)
If you're using PostgreSQL (Supabase or RDS), leverage Row-Level Security. Pass the tenant_id from the JWT into the database session.
-- Example: PostgreSQL RLS Policy
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON orders
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
Common Pitfalls & How to Avoid Them
Pitfall: The "Global Email" Problem
In a shared pool, email addresses must be unique across the entire pool. If user@example.com signs up for Tenant A, they cannot sign up for Tenant B with the same email unless you use a custom username (like a UUID) and allow duplicate emails (which Cognito doesn't natively support well).
The Fix: Use the sub (UUID) as the primary identifier and store tenant-specific profiles in your own database.
Pitfall: Reaching the 25 Custom Attribute Limit
Cognito limits you to 25 custom attributes. Don't waste them on tenant-specific metadata.
The Fix: Store only the tenant_id in Cognito. Store everything else (roles, permissions, preferences) in your application database.
Conclusion
Scaling identity in a multi-tenant environment isn't about finding a bigger box; it's about architecting for flexibility.
- Start with a Shared Pool if your tenants have similar requirements.
- Move to App Clients when you need per-tenant IdP configurations.
- Implement Cell-Based Architecture when you're ready for massive scale.
By moving away from the "One Pool Per Tenant" mindset, you remove the 1,000-pool ceiling and build a system that can grow as fast as your customer base.
What's your approach to handling multi-tenant identity? Have you hit the Cognito limits yet? Drop your thoughts in the comments.
About the Author: Ameer Hamza is a Top-Rated Full-Stack Developer with 7+ years of experience building SaaS platforms, eCommerce solutions, and AI-powered applications. He specializes in Laravel, Vue.js, React, Next.js, and AI integrations — with 50+ projects shipped and a 100% job success rate. Check out his portfolio at ameer.pk to see his latest work, or reach out for your next development project.
Top comments (0)