Architecting Multi-Tenant SaaS: Beyond the 1,000 User Pool Limit in Amazon Cognito

Published: (March 19, 2026 at 07:46 PM EDT)
6 min read
Source: Dev.to

Source: Dev.to

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 (rarely granted for this specific limit)?
  • migrate to Auth0 and watch your margins disappear?
  • re‑architect?

In this deep dive we’ll 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 gets its own user directory, password policies, and 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. Using multiple accounts only kicks 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 and 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”, Tenant B wants “Sign in with Microsoft”, or Tenant C requires a 16‑character password 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 its own App Client ID, and you can associate specific IdPs (SAML, OIDC, Social) with specific App Clients.

The Workflow

  1. Tenant Onboarding – Create a new App Client for the tenant.
  2. Domain Mapping – Map the tenant’s subdomain (e.g., tenant-a.myapp.com) to its specific client_id.
  3. Login Flow – The frontend uses the client_id to initiate login; Cognito routes to the appropriate IdP 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.

CellTenants
Cell 1Tenants 1‑900
Cell 2Tenants 901‑1800
Cell 3Tenants 1801‑2700

The Router Pattern

A Global Router (typically a DynamoDB table + Lambda) 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 not 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 user pool. This can cause onboarding friction when two tenants happen to have users with the same email address. Solutions include:

  • Prefixing emails with a tenant‑specific namespace (e.g., tenantA+user@example.com).
  • Using a secondary identifier (username) for login while keeping email as a contact field.
  • Moving to a hybrid or cell‑based approach where each cell maintains its own uniqueness scope.

# Multi‑Tenant Identity with Amazon Cognito  

## Common Pitfalls & Fixes  

### 1️⃣ Duplicate Email Across Tenants  

- **Problem**: 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 (e.g., a UUID) and allow duplicate emails – something Cognito doesn’t support well out of the box.  

- **The Fix**: Use the **`sub` (UUID)** as the primary identifier and store tenant‑specific profiles in your own database.

---

### 2️⃣ Hitting the 25‑Custom‑Attribute Limit  

- **Problem**: Cognito caps you at **25 custom attributes**. It’s easy to waste them on tenant‑specific metadata.  

- **The Fix**: Store only the **`tenant_id`** in Cognito. Keep everything else (roles, permissions, preferences, etc.) 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 eliminate 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** – Top‑Rated Full‑Stack Developer with 7+ years of experience building SaaS platforms, eCommerce solutions, and AI‑powered applications.  
Specialties: Laravel, Vue.js, React, Next.js, AI integrations.  
- 50+ projects shipped  
- 100 % job success rate  

🔗 Portfolio:   
📩 Reach out for your next development project.  
0 views
Back to Blog

Related posts

Read more »