+1 (726) 207-9872

Multi-Tenant SaaS on Serverless: Tenant Isolation, Noisy Neighbours, and Per-Tenant Cost with Serverless Framework V4

Most of the serverless work we are asked to rescue is not a single product for a single company. It is a SaaS: one codebase, many customers, and a hard requirement that customer A can never see customer B's data. Serverless makes some of that easy (no idle servers per tenant) and some of it harder (a single Lambda execution role that can read everything is one bad if statement away from a breach).

This post covers the multi-tenancy decisions we make on Serverless Framework V4 engagements: the isolation model, how tenant identity flows through the stack, how to enforce it in IAM rather than in application code, how to stop one tenant from consuming all your concurrency, and how to work out what each tenant actually costs you.

Step 1: Pick an isolation model before you write code

There are three common models, and the choice is architectural — retrofitting it later is a migration project.

ModelWhat it meansGood forCost / ops
PooledAll tenants share the same Lambda functions, the same DynamoDB table, the same bucket. Isolation is logical, via partition keys and key prefixes.Self-serve, many small tenants, usage-based pricingCheapest, one stack to deploy, hardest to prove isolation
SiloEach tenant gets its own stack: own functions, own tables, own account.Regulated tenants, enterprise contracts with data-residency or BYO-KMS termsMost expensive, deploy fan-out, per-account quotas
Bridge (pooled + silo tier)Pooled by default; promote a tenant to their own stack when the contract demands it.Almost every SaaS that survives past its first enterprise dealTwo deploy paths, but only one code path if you plan for it

Our default recommendation is bridge: build pooled, but keep every tenant-scoped resource name derived from configuration so that deploying a dedicated stack later is a parameter change, not a rewrite.

# serverless.yml
params:
  default:
    tenantScope: pooled
    tableName: ${self:service}-${sls:stage}-app
  # a silo stage overrides these; the code never hardcodes a name
  acme-prod:
    tenantScope: silo
    tableName: ${self:service}-acme-prod-app

provider:
  name: aws
  runtime: nodejs22.x
  architecture: arm64
  environment:
    TABLE_NAME: ${param:tableName}
    TENANT_SCOPE: ${param:tenantScope}

Step 2: Get tenant identity from the token, never from the request body

The tenant ID must come from something the caller cannot forge. In practice that is a claim in a signed JWT issued by your identity provider — a Cognito custom attribute (custom:tenantId), an Auth0 or Entra ID app claim, or an organisation claim from your own issuer.

With an HTTP API JWT authorizer, the claims arrive in the request context and your handler reads them instead of trusting anything in the payload:

provider:
  httpApi:
    authorizers:
      tenantJwt:
        type: jwt
        identitySource: $request.header.Authorization
        issuerUrl: https://cognito-idp.${aws:region}.amazonaws.com/${param:userPoolId}
        audience:
          - ${param:userPoolClientId}

functions:
  listOrders:
    handler: src/orders.list
    events:
      - httpApi:
          method: GET
          path: /orders
          authorizer:
            name: tenantJwt
// src/tenant.js
export function tenantFromEvent(event) {
  const claims = event.requestContext?.authorizer?.jwt?.claims ?? {};
  const tenantId = claims['custom:tenantId'];
  if (!tenantId || !/^[a-z0-9-]{3,40}$/.test(tenantId)) {
    const err = new Error('missing or malformed tenant claim');
    err.statusCode = 403;
    throw err;
  }
  return tenantId;
}

Two rules we enforce in code review:

  1. No handler ever accepts tenantId as an input parameter. If it is in the body or the query string, it is attacker-controlled.
  2. No data access helper takes a raw key. Every repository function takes (tenantId, ...) and builds the key itself.
// src/repo.js — tenant prefix is constructed, never passed in
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb';

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));

export async function listOrders(tenantId, limit = 50) {
  return ddb.send(new QueryCommand({
    TableName: process.env.TABLE_NAME,
    KeyConditionExpression: '#pk = :pk AND begins_with(#sk, :sk)',
    ExpressionAttributeNames: { '#pk': 'pk', '#sk': 'sk' },
    ExpressionAttributeValues: { ':pk': `TENANT#${tenantId}`, ':sk': 'ORDER#' },
    Limit: limit,
  }));
}

Step 3: Enforce isolation in IAM, not just in code

Application-level scoping is necessary but not sufficient: one missing begins_with and a tenant reads the whole table. The stronger control is to give the request credentials that can only touch one tenant's data. Two mechanisms do this on AWS.

DynamoDB leading-key conditions

DynamoDB supports the dynamodb:LeadingKeys condition, which restricts an identity to items whose partition key matches a value. Combine it with STS session tags so a single role serves every tenant:

resources:
  Resources:
    TenantAccessRole:
      Type: AWS::IAM::Role
      Properties:
        AssumeRolePolicyDocument:
          Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Principal:
                AWS: !GetAtt IamRoleLambdaExecution.Arn
              Action:
                - sts:AssumeRole
                - sts:TagSession
        Policies:
          - PolicyName: tenant-scoped-data
            PolicyDocument:
              Version: '2012-10-17'
              Statement:
                - Effect: Allow
                  Action:
                    - dynamodb:Query
                    - dynamodb:GetItem
                    - dynamodb:PutItem
                    - dynamodb:UpdateItem
                    - dynamodb:DeleteItem
                  Resource: !GetAtt AppTable.Arn
                  Condition:
                    ForAllValues:StringEquals:
                      dynamodb:LeadingKeys:
                        - 'TENANT#${aws:PrincipalTag/tenantId}'
                - Effect: Allow
                  Action:
                    - s3:GetObject
                    - s3:PutObject
                  Resource: !Sub '${AppBucket.Arn}/tenants/${!aws:PrincipalTag/tenantId}/*'

The handler assumes that role once per tenant per container and caches the credentials:

import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts';

const sts = new STSClient({});
const cache = new Map(); // tenantId -> { credentials, expiry }

export async function credentialsFor(tenantId) {
  const hit = cache.get(tenantId);
  if (hit && hit.expiry - Date.now() > 60_000) return hit.credentials;

  const out = await sts.send(new AssumeRoleCommand({
    RoleArn: process.env.TENANT_ROLE_ARN,
    RoleSessionName: `tenant-${tenantId}`.slice(0, 64),
    DurationSeconds: 900,
    Tags: [{ Key: 'tenantId', Value: tenantId }],
  }));

  const credentials = {
    accessKeyId: out.Credentials.AccessKeyId,
    secretAccessKey: out.Credentials.SecretAccessKey,
    sessionToken: out.Credentials.SessionToken,
  };
  cache.set(tenantId, { credentials, expiry: out.Credentials.Expiration.getTime() });
  return credentials;
}

Caching matters: an uncached AssumeRole on every invocation adds latency and burns STS quota. Cache per warm container, keyed by tenant, and expire a minute before the real expiry.

Scoped-down session policies

When you cannot use session tags — for example a bucket layout that predates your tenancy model — pass an inline Policy to AssumeRole that narrows the role's permissions for that session only. The effective permissions are the intersection of the role policy and the session policy, so a bug in the generated policy can only ever reduce access.

A reasonable middle ground: session tags for DynamoDB and S3, and a small number of silo stacks (separate accounts) for tenants whose contract requires physical separation.

Step 4: Stop noisy neighbours from taking the account down

In a pooled model, every tenant shares your account's Lambda concurrency. One tenant bulk-importing ten million rows can starve everyone else's API. Three controls, from cheapest to strongest:

1. Reserved concurrency per workload class. Split "interactive" from "bulk" functions and cap the bulk ones so they can never consume the whole account limit.

functions:
  api:
    handler: src/api.handler
    # no reservation: gets the unreserved pool
  bulkImport:
    handler: src/import.handler
    reservedConcurrency: 20
    timeout: 300
    events:
      - sqs:
          arn: !GetAtt ImportQueue.Arn
          batchSize: 10
          maximumConcurrency: 20

2. Per-tenant queues or SQS maximumConcurrency. For asynchronous work, a queue per tier (not per tenant — you will hit quotas) with maximumConcurrency on the event source mapping gives you a fair-ish share without complex scheduling.

3. API-level throttling keyed by tenant. API Gateway usage plans work when tenants have API keys; for JWT-authenticated traffic, put the tenant ID in a rate-limit key in your authorizer or use a WAF rate-based rule with a custom aggregation key. Return 429 with a Retry-After header rather than letting the backlog grow.

Also set an account-level concurrency alarm. ClaimedAccountConcurrency against your regional quota is the metric that predicts throttling before customers report it.

Step 5: Know what each tenant costs

Usage-based pricing without per-tenant cost data is guesswork. Two complementary approaches:

Infrastructure tags work for silo tenants: tag every resource with tenantId, activate it as a cost allocation tag, and Cost Explorer splits the bill for you.

provider:
  stackTags:
    tenantId: ${param:tenantScope == 'silo' ? param:tenantId : 'pooled'}
    product: ${self:service}

Application metering is the only option for pooled tenants, because a shared Lambda's cost cannot be tagged per caller. Emit a structured metric per request using CloudWatch Embedded Metric Format, with the tenant as a dimension:

import { metricScope, Unit } from 'aws-embedded-metrics';

export const handler = metricScope((metrics) => async (event) => {
  const tenantId = tenantFromEvent(event);
  const started = Date.now();

  metrics.setNamespace('SaaS/Usage');
  metrics.setDimensions({ TenantId: tenantId, Operation: 'listOrders' });

  const result = await listOrders(tenantId);

  metrics.putMetric('Invocations', 1, Unit.Count);
  metrics.putMetric('DurationMs', Date.now() - started, Unit.Milliseconds);
  metrics.putMetric('ItemsRead', result.Count ?? 0, Unit.Count);
  return { statusCode: 200, body: JSON.stringify(result.Items ?? []) };
});

Multiply DurationMs x memoryGB by the Lambda per-GB-second price, add DynamoDB consumed capacity (returned per request when you set ReturnConsumedCapacity: 'TOTAL'), and you have a defensible per-tenant cost model. Watch dimension cardinality: thousands of tenants as a metric dimension gets expensive, so meter the top N tenants by dimension and aggregate the long tail, or ship raw EMF logs to S3 and analyse with Athena.

Step 6: Test isolation like it is a feature

Isolation bugs are silent until they are a disclosure notice. Add these to CI, running against an ephemeral stage:

  • Cross-tenant read test. Authenticate as tenant A, request a known item ID belonging to tenant B, assert 403 or 404 — never 200.
  • Credential test. Assume the tenant role with tag tenant-a, attempt a Query for TENANT#tenant-b, assert AccessDeniedException. This proves the IAM layer works even if the code layer is bypassed.
  • Enumeration test. Assert that no endpoint returns an unscoped Scan result, and that error messages never echo another tenant's identifiers.
  • Onboarding and offboarding tests. Creating a tenant must be idempotent; deleting one must remove data in every store, including S3 versions and backups you are contractually required to purge.

A checklist before you ship

  • Isolation model chosen and written down, with the promotion path to a silo stack.
  • Tenant ID sourced only from a verified token claim, validated against a strict pattern.
  • IAM-level scoping (leading keys or session policies) in addition to code-level scoping.
  • Reserved concurrency separating interactive and bulk workloads; account concurrency alarmed.
  • Per-tenant usage metrics emitted, with cardinality under control.
  • Cross-tenant negative tests running on every pull request.
  • Tenant deletion path implemented and tested, not left as a manual runbook.

Multi-tenancy is where serverless SaaS architectures either scale gracefully or accumulate the kind of debt that stops an enterprise deal in security review. If you are designing one now, or you have a pooled system that needs to serve a tenant with stricter requirements, get in touch — architecture reviews and isolation audits are a large part of what our serverless architecture design and serverless security consulting teams do.