Tenant template stack: tenant identity
Start the tenant template stack, the application plane “cell” that actually serves tenants. This step adds its first part: a Cognito user pool for tenant users, where every user carries their tenant context as token claims.
The tenant template stack
Section titled “The tenant template stack”One CDK stack class (TenantTemplateStack), deployed more than once:
| Stack name | Serves | Deployed by |
|---|---|---|
serverless-saas-tenant-template-pooled |
all Basic / Standard / Premium tenants | you, now (and deploy.sh) |
serverless-saas-tenant-template-<tenantId> |
one Platinum tenant | the provisioning job (step 09) |
bin/serverless-saas.ts picks which one to synthesize from CDK_PARAM_TENANT_ID (default pooled).
Over the next steps it grows: user pool (now), then API + authorizer (04), product service (05), order service (06) and user management (07).
Tenant identity = claims in the ID token
Section titled “Tenant identity = claims in the ID token”The reference solution’s core idea: every request carries the tenant context inside a signed JWT. Nothing in the request body or URL is trusted to say who the tenant is.
The user pool defines custom attributes, and Cognito copies them into the ID token:
| Claim | Example | Used for |
|---|---|---|
custom:tenantId |
tenant-a |
Which tenant’s data the caller can reach (step 04/05) |
custom:userRole |
TenantAdmin / TenantUser |
What the caller may do (step 07) |
custom:tenantTier |
BASIC |
Which API key and usage plan to apply (step 04) |
cognito:groups |
["tenant-a"] |
Each tenant also has a Cognito group, used to list and check a tenant’s users (step 07) |
Users must not be able to edit their own claims
Section titled “Users must not be able to edit their own claims”By default a Cognito app client lets a signed-in user change any of their attributes with just their access token (UpdateUserAttributes), without touching our API. If custom:tenantId were writable, any user could move themselves into another tenant.
So the app client’s writeAttributes allows only email. Tenant attributes are set only by admin APIs (AdminCreateUser / AdminUpdateUserAttributes), which only our backend can call. The original reference solution added this as a security fix in 2026.
Tenant mapping
Section titled “Tenant mapping”The stack writes a row to the mapping table from step 02 (via a CDK AwsCustomResource, which calls DynamoDB PutItem on create, UpdateItem on update and DeleteItem on delete):
{ "tenantId": "pooled", "stackName": "serverless-saas-tenant-template-pooled", "codeCommitId": "local", "waveNumber": "1" }- server/cdk/lib/tenant-template/identity-provider.ts: user pool, custom attributes, app client with restricted
writeAttributes. - server/cdk/lib/tenant-template/tenant-template-stack.ts: the stack, the mapping row and the outputs
TenantUserpoolId/UserPoolClientId. - server/cdk/lib/interfaces/identity-details.ts:
{name: "Cognito", details: {userPoolId, appClientId}}, passed to Lambdas from step 04 on. - scripts/dev-create-tenant-user.sh: dev helper that creates a tenant user directly in Cognito (until onboarding exists in step 08).
- scripts/tenant-token.sh: signs in a tenant user and prints the ID token (
TOKEN_TYPE=AccessTokenfor the access token).
Deploy
Section titled “Deploy”./scripts/deploy.sh you@example.comVerify
Section titled “Verify”Create a tenant admin and inspect their token:
./scripts/dev-create-tenant-user.sh tenant-a BASIC TenantAdmin alice 'Passw0rd!Saas'TOKEN=$(./scripts/tenant-token.sh alice 'Passw0rd!Saas')echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null # or paste into jwt.ioClaims from the test deployment:
{ "cognito:groups": ["tenant-a"], "custom:tenantTier": "BASIC", "cognito:username": "alice", "custom:tenantId": "tenant-a", "custom:userRole": "TenantAdmin" }Prove a user can’t move themselves to another tenant:
ACCESS=$(TOKEN_TYPE=AccessToken ./scripts/tenant-token.sh alice 'Passw0rd!Saas')aws cognito-idp update-user-attributes --access-token "$ACCESS" --user-attributes Name=custom:tenantId,Value=tenant-b# NotAuthorizedException: A client attempted to write unauthorized attributeCheck the mapping row:
aws dynamodb scan --table-name <TenantMappingTable name from the bootstrap stack>Step 04 puts an API in front of the pool: a Lambda authorizer that verifies this token and turns the claims into tenant-scoped AWS credentials.