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 themselves scoped to that tenant, via an IAM policy condition (dynamodb:LeadingKeys) that restricts DynamoDB access to keys prefixed TENANT#{tenantId}. Every partition key in the table is prefixed that way, deliberately, as part of the single-table design.
By the time a Lambda handler executes, it isn't holding generic database access and hoping someone remembered to add a WHERE tenantId = ? clause. It's holding credentials that are incapable of reading another tenant's rows, full stop, enforced by AWS itself. A handler that "forgets" to filter by tenant doesn't leak data — its query against another tenant's keys simply fails at the IAM layer.
The same tenant context flows through everything downstream: EventBridge events carry tenantId in the payload, Cognito's shared user pool carries it in custom claims (custom:tenantId, custom:userRole, custom:tenantTier), and rate limiting is enforced per-tenant by plan tier. One rule, stated once in overview.md, sums up the whole model: always extract tenantId from the authorizer context, never from the request body or path params. The request body is user-controlled input; the authorizer context is not.
What this buys, concretely
- A forgotten filter fails safe. The failure mode of a missing tenant check isn't a data leak — it's an access-denied error, because the credentials themselves don't extend that far.
- New engineers can't quietly regress this. Tenant isolation isn't a pattern you have to remember to apply per-handler; it's structural. That matters a lot for a small team where "the one person who really understands the security model" isn't a sustainable dependency.
- It's independently auditable. "We check tenantId in code" is a claim a customer's security team has to trust on your word. "Tenant isolation is enforced by AWS IAM policy, scoped per-request via STS" is a claim they can verify against the IAM policy itself.
What it costs
Nothing comes free. STS AssumeRole on every authenticated request adds a real hop — measurable latency compared to a handler that just trusts a broad IAM role and checks tenantId in code. For InvocaCare's traffic profile that cost is easily worth it, but it's not automatically the right call for every workload; a system doing very high request volume with less sensitive data might reasonably choose a cheaper, code-level isolation strategy and accept the discipline burden that comes with it.
The other cost is conceptual complexity: anyone debugging a "why did this request fail" issue now has to know that a 403 might come from route RBAC, from an access-control check, or from IAM denying the underlying DynamoDB call — three layers instead of one. That's a real cost, and it's worth documenting clearly rather than assuming it's obvious, which is part of why this lives as its own architecture doc rather than a comment buried in the authorizer code.
Why it's the right trade for this product
For a system holding PHI, "trust me, we filter correctly" isn't a strong enough answer, and it doesn't need to be the strongest possible answer if a stronger one is available for a bounded cost. Pushing isolation down to the IAM layer turns a discipline problem — something every future line of code has to get right, forever — into an infrastructure guarantee that's true regardless of what any individual handler does. That's the trade multi-tenant SaaS is always making at some layer; the question is just which layer you push it to, and how much you're willing to pay to make it structural instead of habitual.