
Tenant Isolation You Don't Have to Trust Application Code For
Instead of trusting every handler to filter by `tenantId`, InvocaCare pushes tenant isolation down to AWS IAM itself — a Lambda authorizer calls STS AssumeRole to hand each request credentials that are structurally incapable of reading another tenant's data
Part 2 of 7 — InvocaCare Architecture series
If you're building a multi-tenant SaaS product, there's one question every serious buyer eventually asks, directly or indirectly: how do you know Clinic A can never see Clinic B's data? For a healthcare answering-service platform handling PHI, that question isn't a nice-to-have answer to have ready — it's close to the whole sale.
The easy answer is "we check tenantId on every query." The honest follow-up is: checked by what, and what happens the one time a developer forgets?
The business pressure
InvocaCare is a pooled multi-tenant architecture — every clinic shares the same infrastructure, the same DynamoDB table, the same Lambda functions, the same event bus. Pooling is what makes the economics work for a small clinic on our entry-level tier; dedicating infrastructure per tenant would price most of our actual customers out. But pooling also means every single request, from every clinic, is one bug away from reading the wrong tenant's data if isolation lives only in application logic.
"Only in application logic" is doing a lot of work in that sentence. Application-level tenant checks are a discipline problem: every new handler, every new query, every future engineer who touches the codebase has to remember to filter by tenantId, correctly, every time. That's not a knock on anyone's carefulness — it's just a structurally fragile place to put your one non-negotiable guarantee.
The technical decision
So tenant isolation at InvocaCare isn't enforced by application code remembering to filter — it's enforced by AWS IAM, on every request, before a handler's business logic ever runs.
Here's the flow:
Request (Authorization: Bearer )
→ API Gateway HTTP API
→ Lambda Authorizer
1. Validates JWT against Cognito's public keys
2. Extracts tenantId, userRole, tenantTier from Cognito custom claims
3. Checks route-level scope (required roles per route)
4. Calls STS AssumeRole → tenant-scoped temporary credentials
(IAM policy includes a dynamodb:LeadingKeys condition:
TENANT#{tenantId}*)
5. Returns those credentials + context to API Gateway
→ Lambda Handler
- Uses the tenant-scoped STS credentials for all AWS SDK calls
- Tenant isolation is enforced at the IAM layer, not in handler code
The key move is step 4. The authorizer doesn't just decide "yes, this JWT is valid, let it through" — it calls STS AssumeRole and gets back temporary AWS credentials that are scoped to that tenant, via an IAM policy condition () that restricts DynamoDB access to keys prefixed . Every partition key in the table is prefixed that way, deliberately, as part of the single-table design.