Skip to content

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.

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).

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.

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" }
Terminal window
./scripts/deploy.sh you@example.com

Create a tenant admin and inspect their token:

Terminal window
./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.io

Claims 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:

Terminal window
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 attribute

Check the mapping row:

Terminal window
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.