DEV Community

Cover image for Tenant Isolation at the API Edge: Per-Tenant API Keys, Usage Plans and Throttling on Amazon API Gateway
Fonchu
Fonchu

Posted on

Tenant Isolation at the API Edge: Per-Tenant API Keys, Usage Plans and Throttling on Amazon API Gateway

A worked example: a multi-company employee directory, built with AWS CDK, Amazon Cognito and React.


Summary

  • When every customer of a multi-tenant API shares one rate limit, one busy customer can degrade service for all the others. This is the noisy neighbour problem.

  • Amazon API Gateway solves it at the edge, with no code in the request path: issue one API key per tenant, attach each key to a usage plan per pricing tier, and API Gateway enforces the plan's limits for each key independently.

  • API keys identify a tenant for throttling and metering; they are not an authentication mechanism. Authenticate with Amazon Cognito, carry the tenant in an immutable JWT claim, and verify that the API key belongs to that tenant.

  • Rate isolation is not enough when the data is personal. Scope every read to the tenant named in the token.

  • Changing a tenant's tier means moving its key to another plan, not editing the plan.

  • Plan for the service limits early: 10,000 API keys per account per Region, a quota that cannot be increased, and the Lambda concurrency behind the API, which can cap traffic before any usage plan does.

The design uses one API key per company and one usage plan per tier, and adds key-to-tenant binding, data isolation, tier changes, key rotation, tenant suspension, and usage reporting. A deployable sample accompanies the post on GitHub: AWS CDK infrastructure and a React platform console with a traffic lab.


1. The scenario: a multi-company employee directory

Consider a SaaS product that hosts employee directories for many companies. Each customer company has:

  • Employees who sign in to look up colleagues: name, title, department, and email.

  • Integrations, such as an HR system that synchronises the directory or an intranet page that searches it.

  • A subscription tier: Basic for small teams, Premium for growing companies, Enterprise for large organisations.

In multi-tenant terms, each company is a tenant. The platform owes every company two guarantees:

  1. Data isolation. One company must never see another company's employees.

  2. Performance isolation. One company's traffic must never degrade another company's service.

This post focuses on the second guarantee and shows how it fits together with the first.

2. The problem with a shared rate limit

A typical first version of the API has a single throttle on the API Gateway stage, for example 20 requests per second. That limit is shared by every company combined.

Now one large customer runs a nightly HR synchronisation that pages through its entire directory as fast as it can. It consumes the shared allowance, and employees at every other company receive 429 Too Many Requests when they search for a colleague. The platform also cannot answer basic operational questions:

  • How much is each company actually using?

  • Can one misbehaving integration be switched off without affecting anyone else?

  • Can customers on a higher tier be offered a higher limit?

3. How API Gateway throttling works

API Gateway throttles with a token bucket: tokens are added at the configured rate (requests per second), and the bucket holds at most burst tokens. Each request consumes one token; when the bucket is empty, the request is rejected with a 429.

Limits are applied in four layers, in this order:

  1. Per-client limits from a usage plan (per API key), or per-method limits within a plan

  2. Per-method limits configured on the stage

  3. The account-level limit per Region (by default 10,000 requests per second with a burst of 5,000; the rate can be raised)

  4. AWS Regional throttling

The first layer is the one that matters for tenant isolation. The AWS documentation states:

"Throttling and quota limits apply to requests for individual API keys that are aggregated across all API stages within a usage plan."

If a usage plan allows 10 requests per second and ten companies' keys belong to it, each company receives 10 requests per second. They do not share a single allowance.

One caveat, also from the documentation: throttles and quotas are "applied on a best-effort basis and should be thought of as targets rather than guaranteed request ceilings." Usage plans are the right tool for fairness and tiered pricing. For hard abuse protection or cost control, add AWS WAF rate-based rules and AWS Budgets.

3.1 A worked example

The sample's Basic plan has a rate of 2 requests per second and a burst of 4. Picture its bucket:

  • It holds at most 4 tokens, and it is full when the company has been idle.

  • Every request takes one token. A request that finds the bucket empty receives a 429.

  • Tokens are added back at 2 per second.

So a Basic company can briefly exceed 2 requests per second, until its saved tokens run out. After that it gets 2 per second, however much it sends:

A Basic company sends Expected in the first second Expected every second after that
5 requests per second About 4–5 succeed (the bucket was full) About 2 succeed, 3 throttled
10 requests per second About 4–6 succeed in theory; often more in practice (see below) About 2 succeed, 8 throttled

Two details are easy to miss:

  • Users do not matter, keys do. All of a company's employees share its key. Ten employees sending one request per second each are indistinguishable from one integration sending ten: the company has 10 requests per second against a limit of 2.

  • Only the noisy company is affected. A Premium company calling at the same moment still has its own 10 requests per second.

Here is the first case measured with the sample's traffic lab (section 9). For 20 seconds, Acme Robotics (Basic) sends 5 requests per second while Initech Labs (Premium) also sends 5:

Traffic lab: a Basic company at 5 requests per second is held to about 2 successful requests per second, while a Premium company at the same rate is unaffected

Acme Robotics' first two seconds are fully green while the saved tokens last. After that it settles at about 2 successful requests per second, with the rest throttled: 43 successful and 57 throttled in total, 2.15 per second on average. Initech Labs is not affected: all 100 of its requests succeed.

Doubling Acme Robotics to 10 requests per second throttles more requests, but the successful rate barely moves (46 successful, 154 throttled, 2.3 per second):

Traffic lab: at 10 requests per second the Basic company still gets about 2 successful requests per second

Notice the first two seconds: 10 and 8 requests succeeded, more than a 4-token bucket suggests. This is the best-effort behaviour described above. Over any period longer than a few seconds, the limit holds.

4. Architecture

Architecture

Every request from a company's directory app passes three independent checks:

# Question Answered by
1 Who is calling, and for which company? API Gateway's built-in Cognito authorizer validates the JWT; its custom:tenant_id claim names the company
2 Which plan, and how fast? API Gateway validates x-api-key and applies that key's usage plan, returning 429 when it is exceeded
3 Does the key belong to the caller's company? The directory Lambda compares the request's API key ID with the company's stored key ID

A fourth rule governs the data itself: every read is scoped to the company in the token.


sequenceDiagram
    autonumber
    participant App as Company directory app
    participant C as Amazon Cognito
    participant GW as API Gateway
    participant UP as Usage plan (per key)
    participant L as Directory Lambda
    participant DB as DynamoDB

    App->>C: Employee signs in
    C-->>App: ID token (custom:tenant_id)
    App->>GW: GET /employees, Authorization: ID token, x-api-key
    GW->>GW: Cognito authorizer validates the JWT
    GW->>UP: Valid key? Tokens left?
    alt Over the key's rate or quota
        UP-->>App: 429 Too Many Requests
    else Unknown or disabled key
        UP-->>App: 403 Forbidden
    else Within limits
        GW->>L: Invoke (identity.apiKeyId, claims)
        L->>DB: Load company record
        alt Key does not belong to the token's company
            L-->>App: 403 Forbidden
        else
            L->>DB: Query employees for the token's company only
            L-->>App: 200 Employee directory
        end
    end
Enter fullscreen mode Exit fullscreen mode

Both gateway checks are performed by API Gateway itself. No Lambda function runs in front of the throttle, so rejected and throttled requests cost nothing beyond API Gateway. If you use a Lambda authorizer instead, verify whether it runs before the API key check: if it does, a flooding tenant invokes it on every request, and a WAF rate-based rule in front becomes worthwhile.

Platform administration (onboarding companies, changing tiers, rotating and suspending keys, reading usage) is a separate set of /admin/* routes that call the API Gateway management APIs.

5. Design choice: a key per company, or a key per tier?

The AWS Partner Network post referenced above also discusses the main alternative: one API key per tier, shared by every tenant on that tier.

With a shared key, tenants never hold a key. The API's key source is set to AUTHORIZER, and a Lambda authorizer returns the tier's key. Per the AWS documentation, the authorizer "must return one of the usage plan's API keys as the usageIdentifierKey property value":

// Shared-key variant, shown for comparison (not used by the sample)
const api = new apigw.RestApi(this, 'Api', { apiKeySourceType: apigw.ApiKeySourceType.AUTHORIZER });

// Inside a Lambda authorizer, after verifying the JWT:
return {
  principalId: payload.sub,
  policyDocument: allow(event.methodArn),
  usageIdentifierKey: TIER_KEYS[await tierOf(payload['custom:tenant_id'])],
};
Enter fullscreen mode Exit fullscreen mode

The built-in Cognito authorizer cannot return a key, so this variant requires a Lambda authorizer that validates the JWT itself.

One key per company (this sample) One key per tier (shared)
Noisy-neighbour isolation Each company has its own bucket Companies on the same tier share a bucket
Quota Per company Shared across the tier
Usage reporting (GetUsage) Per company Per tier only
Suspending one company at the gateway Disable its key Not possible; must be enforced in the authorizer
Number of keys One per company, up to 10,000 per account and Region (hard limit) One per tier
Onboarding A CreateApiKey call (5 per second per account) A database write
Tier change Move the key between plans; can take minutes Immediate, through the authorizer
Authorizer Built-in Cognito authorizer Lambda authorizer on every uncached request

The AWS post summarises the trade-off: a shared key means fewer keys to manage, but "the solution is more susceptible to noisy neighbor effect." Because performance isolation is the goal here, the sample uses one key per company.

Shared tier keys remain a sound choice for free or trial tiers with many small tenants, where isolation matters less than conserving the 10,000-key budget. A hybrid is common: a shared key for the free tier and dedicated keys for paid tiers. Because an API has a single key source, a hybrid uses AUTHORIZER, which means the authorizer must know the dedicated keys' values (kept in AWS Secrets Manager) rather than only their IDs.

6. Implementation

The code below is taken from the sample. Paths are relative to infra/.

6.1 Tiers as configuration

All limits live in one place. Each value applies per company key:

// lib/tiers.ts
export const TIERS = {
  basic:      { throttle: { rateLimit: 2,  burstLimit: 4 },   quota: { limit: 5_000,   period: 'DAY' } },
  premium:    { throttle: { rateLimit: 10, burstLimit: 20 },  quota: { limit: 50_000,  period: 'DAY' } },
  enterprise: { throttle: { rateLimit: 50, burstLimit: 100 }, quota: { limit: 500_000, period: 'DAY' } },
} as const;

export const STAGE_THROTTLE = { rateLimit: 100, burstLimit: 200 };   // all companies combined
Enter fullscreen mode Exit fullscreen mode

The sample's limits are deliberately small so that throttling is visible from a browser. Production values would typically be an order of magnitude higher.

The console reads these values from the deployed stack, so what it shows always matches what API Gateway enforces:

The console's Plans table: Basic 2 requests per second, Premium 10, Enterprise 50, and the stage limit of 100 for all companies combined

6.2 One usage plan per tier, and a stage ceiling

// lib/tenant-api-stack.ts
const api = new apigw.RestApi(this, 'Api', {
  apiKeySourceType: apigw.ApiKeySourceType.HEADER,   // clients send x-api-key
  deployOptions: {
    stageName: 'demo',
    throttlingRateLimit: STAGE_THROTTLE.rateLimit,
    throttlingBurstLimit: STAGE_THROTTLE.burstLimit,
  },
});

for (const [tier, { throttle, quota }] of Object.entries(TIERS)) {
  api.addUsagePlan(`${tier}UsagePlan`, {
    name: usagePlanName(tier),
    throttle,                                          // applied to each key separately
    quota: { limit: quota.limit, period: apigw.Period[quota.period] },
    apiStages: [{ api, stage: api.deploymentStage }],
  });
}
Enter fullscreen mode Exit fullscreen mode

Size the stage limit against what you sell. One hundred Premium companies at 10 requests per second can send 1,000 requests per second between them. If the stage allows less, the shared stage bucket becomes the bottleneck and the noisy-neighbour problem returns.

6.3 Identity: Amazon Cognito with an immutable tenant claim

AWS is explicit that API keys are not an authentication mechanism:

"Don't use API keys for authentication or authorization to control access to your APIs."

Employees authenticate with Amazon Cognito. Each user carries a tenant_id custom attribute, which Cognito includes in the ID token as the custom:tenant_id claim:

const userPool = new cognito.UserPool(this, 'TenantUsers', {
  featurePlan: cognito.FeaturePlan.LITE,
  selfSignUpEnabled: false,
  customAttributes: { tenant_id: new cognito.StringAttribute({ mutable: false }) },
});
Enter fullscreen mode Exit fullscreen mode

The attribute must be immutable. If users could change it, they could move themselves into another company.

For server-to-server integrations such as an HR system sync, the same model works with the OAuth 2.0 client credentials grant: one Cognito app client per company, with the company identified by the token's client_id.

6.4 Requiring a JWT and an API key

const tenantAuth: apigw.MethodOptions = {
  authorizationType: apigw.AuthorizationType.COGNITO,
  authorizer: new apigw.CognitoUserPoolsAuthorizer(this, 'TenantAuthorizer', {
    cognitoUserPools: [userPool],
    resultsCacheTtl: cdk.Duration.minutes(5),
  }),
  apiKeyRequired: true,
};

api.root.addResource('employees').addMethod('GET', new apigw.LambdaIntegration(employeesFn), tenantAuth);
Enter fullscreen mode Exit fullscreen mode

A method does not need two authorizers to enforce both checks. apiKeyRequired is a method setting that API Gateway enforces alongside the authorizer.

A request from a company's directory app therefore carries both credentials:

GET /employees
Authorization: eyJraWQiOi...      (Cognito ID token)
x-api-key: 9vKq2n...              (the company's API key)
Enter fullscreen mode Exit fullscreen mode

6.5 Binding the key to the company

API Gateway confirms that the key is valid. It does not know whose it is. Without one more check, an employee of company A could send company B's key and consume B's allowance.

The Lambda receives the verified key ID from API Gateway, so the check is a single lookup:

// lambda/employees.ts
const tenantId = event.requestContext.authorizer?.claims?.['custom:tenant_id'];  // validated JWT claim
const apiKeyId = event.requestContext.identity?.apiKeyId;                         // from the key check

// Both reads are scoped to the token's company, so they run in parallel.
// Nothing is returned until the key check has passed.
const [company, employees] = await Promise.all([getTenant(tenantId), listEmployees(tenantId)]);
if (!company || company.apiKeyId !== apiKeyId) {
  return error(403, "x-api-key does not belong to the caller's company");
}
Enter fullscreen mode Exit fullscreen mode

Running the two reads in parallel is not only about latency. Every request occupies a Lambda execution for as long as it runs, and section 8 shows why that time matters.

Store the key ID, never the key value. The ID is what the request context provides, and it is of no use to an attacker.

A useful consequence: a company's API key only works together with a valid token from the same company. A leaked key cannot be used by anyone outside that company.

6.6 Data isolation: scope every read to the token's company

A directory holds personal data, so performance isolation must sit on top of strict data isolation. The employee table is partitioned by company, and the company always comes from the validated token, never from the request:

// lambda/lib/employees.ts
export const listEmployees = async (tenantId: string, department?: string) => {
  const result = await db.send(new QueryCommand({
    TableName: env('EMPLOYEES_TABLE'),
    KeyConditionExpression: 'tenantId = :tenantId',
    ExpressionAttributeValues: { ':tenantId': tenantId },
  }));
  // optional department filter ...
};
Enter fullscreen mode Exit fullscreen mode

No query parameter, path segment or header can select another company. For defence in depth at scale, consider DynamoDB fine-grained access control with the dynamodb:LeadingKeys condition, so that the function's credentials can only read the caller's partition.

6.7 Onboarding a company

Onboarding creates the company's first Cognito user, issues an API key into its tier's plan, seeds its directory and writes the company record. If any step fails, the earlier steps are rolled back.

// lambda/lib/api-keys.ts
export const issueApiKey = async (tenantId: string, tier: string) => {
  const usagePlanId = await planIdFor(tier);
  const created = await apigw.send(new CreateApiKeyCommand({
    name: `tenant-demo-${tenantId}`,   // the name is chosen by the platform
    enabled: true,                      // the value is generated by API Gateway
  }));
  try {
    await apigw.send(new CreateUsagePlanKeyCommand({ usagePlanId, keyId: created.id, keyType: 'API_KEY' }));
  } catch (err) {
    await revokeApiKey(created.id);     // never leave an orphaned key
    throw err;
  }
  return { id: created.id!, value: created.value! };
};
Enter fullscreen mode Exit fullscreen mode

A new key is not usable immediately. In the sample, requests with a newly created key were rejected with a 403 (INVALID_API_KEY in the access log) for about 45–65 seconds, even though the key was already in its plan. Tell new customers to expect this, or have onboarding wait until a test call succeeds.

A key's name and its value are distinct. The platform chooses the name. API Gateway generates the value, as AWS recommends, and the value is returned to the company once. The company record stores only apiKeyId and the last four characters for display.

6.8 Pitfall: the CloudFormation dependency cycle

The natural approach is to pass each usage plan's ID to the admin Lambda as an environment variable. CloudFormation rejects it, because the references form a cycle:

Lambda → usage plan → stage → deployment → method → Lambda
Enter fullscreen mode Exit fullscreen mode

Instead, pass the plan names, which are plain strings, and resolve the IDs at runtime, caching them per execution environment:

environment: { USAGE_PLAN_NAMES: JSON.stringify(usagePlanNames) }
Enter fullscreen mode Exit fullscreen mode

An SSM parameter written by the stack is an equally valid alternative.

Make the plan names unique in the account. Plan names are not unique in API Gateway, and the lookup takes the first plan with a matching name. If a second copy of the stack is deployed, for example under a new stack name, both copies have a plan called basic. A new company's key can then be added to the other API's plan, and every request it makes is rejected with a 403. The sample prefixes each plan name with the stack name:

// lib/tiers.ts
export const usagePlanName = (stackName: string, tier: string) => `${stackName}-${tier}`;
Enter fullscreen mode Exit fullscreen mode

6.9 Upgrades and downgrades

A usage plan's limits apply to every company on that tier, so editing a plan to upgrade one company would upgrade them all. Instead, move the company's key to the target tier's plan:

// lambda/lib/api-keys.ts
export const syncTier = async (apiKeyId: string, tier: string) => {
  const targetId = await planIdFor(tier);
  const current = await apigw.send(new GetUsagePlansCommand({ keyId: apiKeyId }));
  const currentIds = (current.items ?? []).map((plan) => plan.id!);

  for (const id of currentIds.filter((id) => id !== targetId)) {
    await apigw.send(new DeleteUsagePlanKeyCommand({ usagePlanId: id, keyId: apiKeyId }));
  }
  if (!currentIds.includes(targetId)) {
    await apigw.send(new CreateUsagePlanKeyCommand({ usagePlanId: targetId, keyId: apiKeyId, keyType: 'API_KEY' }));
  }
};
Enter fullscreen mode Exit fullscreen mode

Points to note:

  • The key does not change. The company's integrations keep working without reconfiguration.

  • Remove before adding. "A given API key can only be associated with one usage plan for each stage of your API."

  • Changes are not instant. After a key is added to a usage plan, "the update operation might take a few minutes to complete." Schedule tier changes accordingly, or inform the customer.

  • Make it idempotent. syncTier converges on the desired state, so a failed change can simply be retried. The handler updates the company record first, then synchronises the key.

  • Use a single code path. If the tier is edited directly in the database, the key remains in its old plan.

6.10 Rotation and suspension

Rotation issues a new key, points the company at it with a conditional write and then deletes the old key:

const next = await issueApiKey(tenantId, company.tier);
await tenants.replaceApiKey(tenantId, company.apiKeyId, { id: next.id, last4: next.value.slice(-4) });
await revokeApiKey(company.apiKeyId);
Enter fullscreen mode Exit fullscreen mode

The conditional write (apiKeyId = :previous) prevents two concurrent rotations from both succeeding. If deleting the old key fails, the key is still harmless: it no longer matches the company record, so the binding check rejects it.

Suspension disables the key. API Gateway then rejects it with a 403 before any application code runs, and only that company is affected:

await apigw.send(new UpdateApiKeyCommand({
  apiKey: apiKeyId,
  patchOperations: [{ op: 'replace', path: '/enabled', value: 'false' }],
}));
Enter fullscreen mode Exit fullscreen mode

6.11 Usage reporting and observability

API Gateway meters usage per key. GetUsage returns a daily [used, remaining] pair against the plan's quota:

const result = await apigw.send(new GetUsageCommand({ usagePlanId, keyId: apiKeyId, startDate, endDate }));
const days = result.items?.[apiKeyId] ?? [];
Enter fullscreen mode Exit fullscreen mode

This is sufficient for a usage page, alerting or usage-based billing. API Gateway meters usage but does not bill; invoicing remains the platform's responsibility.

For request-level visibility, include the key and user in the stage's access logs:

accessLogFormat: apigw.AccessLogFormat.custom(JSON.stringify({
  requestId: '$context.requestId',
  status: '$context.status',
  apiKeyId: '$context.identity.apiKeyId',      // identifies the company
  userSub: '$context.authorizer.claims.sub',   // identifies the employee
  errorType: '$context.error.responseType',    // THROTTLED, QUOTA_EXCEEDED, INVALID_API_KEY, ...
})),
Enter fullscreen mode Exit fullscreen mode

Identifying which company is being throttled then becomes a CloudWatch Logs Insights query.

6.12 Pitfall: CORS on gateway responses

When a browser calls the API, responses generated by API Gateway itself, such as a 429 or 403, carry no CORS headers by default. The browser then hides the status and reports an opaque network error. Add the headers to the gateway responses:

api.addGatewayResponse('Default4xx', {
  type: apigw.ResponseType.DEFAULT_4XX,
  responseHeaders: { 'Access-Control-Allow-Origin': "'https://app.example.com'" },
});
Enter fullscreen mode Exit fullscreen mode

7. Best practices

Isolation

  • Issue one API key per tenant and one usage plan per tier. Per-key limits are what contain noisy neighbours.

  • Keep the stage limit, and the account limit, above the combined allowance you sell.

  • Use per-method throttles within a plan for expensive operations, such as a full directory export.

  • Add AWS WAF rate-based rules for abuse protection. Usage plans are best-effort targets.

  • Remember that limits apply per tenant, not per user. Within a company, users share the company's allowance.

Security

  • Never treat an API key as a credential. Pair it with Cognito, IAM or OAuth.

  • Carry the tenant in an immutable claim, and bind the API key to that tenant.

  • Scope every data access to the tenant in the token.

  • Let API Gateway generate keys, store only key IDs, and show key values once.

Operations

  • Change tiers by moving keys between plans, through one idempotent code path.

  • Expect plan membership changes to take minutes.

  • Log apiKeyId on every request, and use GetUsage for per-tenant metering.

  • Keys created at runtime are not stack resources. Offboard tenants before deleting the stack.

  • Grant the admin function least-privilege access to the specific API Gateway paths it manages.

8. Service limits to plan around

From the API Gateway quotas documentation at the time of writing:

Quota Default Adjustable
API keys per account per Region 10,000 No
Usage plans per account per Region 300 Yes
Usage plans per API key 10 Yes
Method-level throttling settings per stage in a usage plan 20 Yes, through AWS Support
Account throttle per Region 10,000 requests/s, burst 5,000 Rate only
CreateApiKey and DeleteApiKey 5 requests/s per account No
UpdateUsagePlan 1 request every 20 s per account No
Lambda concurrent executions per Region (all functions) 1,000; new accounts can start much lower, for example 10 Yes, through Service Quotas

Implications:

  • 10,000 tenants per account and Region is a hard ceiling for the per-key pattern. Beyond it, use a cell-based architecture with tenants distributed across accounts or Regions, or move the smallest tenants to shared tier keys (section 5).

  • Onboarding is rate-limited. Bulk imports need a queue with retries. The sample returns 429 when CreateApiKey is throttled.

  • Never edit a plan to change one tenant. Besides affecting every tenant on the tier, UpdateUsagePlan is limited to one call every 20 seconds.

  • Lambda concurrency is a shared ceiling behind the API. Every successful request runs the directory function, and the account can only run a limited number of executions at once. Above that, Lambda rejects the call and API Gateway returns a 5xx, not a 429, for every tenant alike. An account limited to 10 concurrent executions served roughly 50 requests per second in the sample before errors appeared. Check the limit with aws lambda get-account-settings, raise it before launch, and keep functions on the request path short: the sample gives the directory function 1,024 MB (more memory also means more CPU, so faster cold starts) and runs its reads in parallel.

9. Running the sample

npm install
npm test           # CDK assertion tests, no AWS calls
npm run deploy     # deploys the stack and writes web/.env.local
npm run web        # starts the console on http://localhost:5173
Enter fullscreen mode Exit fullscreen mode
  1. Retrieve the admin token using the AdminTokenCommand stack output, open the console and connect.

  2. On Companies, select New company and create Acme Robotics on Basic and Initech Labs on Premium. Each receives a Cognito user, an API key and a sample directory. The credentials are shown once.

    The New company dialog, with Acme Robotics on the Basic tier

  3. Wait about a minute for the new keys to become active (section 6.7). Meanwhile, open a company's detail panel and its Directory tab. Each company sees only its own employees.

  4. Open the Traffic lab and set both companies to 5 requests per second for 20 seconds.

    Traffic lab controls: each company's rate and limit, presets relative to each company's limit, the run duration, and the combined total

*   Each company's own limit is shown next to its tier.

*   **Every company at** sets all companies to a multiple of their own limit: ½×, 1×, 2× or 5×.

*   The total line compares the combined rate with the two ceilings shared by all companies: the stage limit, and an estimate of what the account's Lambda concurrency can serve.


Select **Run for 20s**. The lab first sends one request per company to sign in and warm up the function, then starts the clock. The result should look like the first chart in section 3.1.
Enter fullscreen mode Exit fullscreen mode
  1. Set Acme Robotics to 10 requests per second and run again. The successful rate stays at about 2 per second; only the throttled count grows.

  2. Change Acme Robotics to Premium in its detail panel, allow a few minutes for the change to take effect, and run again with the same key.

  3. Suspend Initech Labs' key. API Gateway rejects every request with a 403.

  4. Open the Usage tab to view a company's daily request count; the Overview page totals today's requests across all companies.

If the combined rate goes above what the account's Lambda concurrency can serve, the lab warns that extra requests will fail with 5xx errors rather than 429s, and offers to scale every company down proportionally:

Traffic lab warning: 80 requests per second in total is above the roughly 50 that this account's Lambda limit can serve

The traffic lab simulates a rate per company, not a population of users. Each company signs in as a single user and sends evenly spaced requests from the browser. Because limits apply per key, this produces the same throttling as many users sharing the key at the same total rate.

To clean up, delete the companies in the console (this removes their API keys, Cognito users and directories), then run npm run destroy --workspace=infra.

10. Conclusion

Per-tenant API keys and per-tier usage plans give a multi-tenant API effective performance isolation without adding code to the request path. API Gateway enforces the limits, and the platform gains per-tenant quotas, per-tenant suspension and per-tenant usage data.

The design decisions that matter most are:

  • keep authentication separate from the API key;

  • bind each key to the tenant in the token;

  • scope every data access to that tenant;

  • treat tiers as plans that keys move between;

  • plan for the 10,000-key ceiling before it is reached;

  • size what sits behind the gateway, such as Lambda concurrency, for the traffic the plans allow.

References

Top comments (0)