Guide · AWS API Gateway · Rate Limiting
AWS API Gateway Usage Plans for MCP Servers — API Keys, Throttling, and Quota Enforcement
API Gateway usage plans bind API keys to per-key throttle limits (requests per second) and hard quotas (requests per day, week, or month) — giving MCP server operators fine-grained control over how much each client or tier can consume. Without usage plans, every MCP client shares a single account-level throttle and you have no per-client visibility into who is driving Lambda costs. Usage plans solve this by associating a key-per-client with its own rate and burst limits, enabling you to offer a free tier at 10 req/sec and a premium tier at 1,000 req/sec from the same API deployment. Note that usage plans are a REST API feature — if you are running on HTTP API (v2), route-level throttling exists but API keys and quotas do not. For securing those keys with custom business logic, combine usage plans with a Lambda authorizer; for staged rollouts of new MCP server versions, see API Gateway canary deployments.
TL;DR
Create a usage plan with throttle (rateLimit req/sec, burstLimit concurrent) and quota (limit requests per DAY/WEEK/MONTH). Create an API key. Call create-usage-plan-key to link the key to the plan. Associate the plan with your API stage via create-usage-plan apiStages. Clients send the key in the x-api-key header (default) or as a ?x-api-key= query parameter (not recommended — query params are logged). CloudWatch metric Count broken down by ApiId + Stage + KeyId dimensions enables per-client billing analytics without any custom instrumentation.
What usage plans solve
API Gateway imposes a default account-level throttle of 10,000 requests per second with a burst of 5,000. Every API, every stage, and every client shares this pool. A single misbehaving MCP client that fires a tight retry loop can exhaust the account budget, causing 429 responses for all other clients. Usage plans break this shared fate by assigning limits at the key level.
There are two distinct limiting mechanisms usage plans provide, and it is important not to confuse them:
- Throttle (rate/burst): A continuous rate limit expressed in requests per second (
rateLimit) and a token-bucket burst allowance (burstLimit). Exceeding the burst triggers a 429 with the messageToo Many Requestsimmediately. This is a real-time constraint — a client that pauses and then fires a burst of requests can use up toburstLimitin a single second. - Quota: A hard cap on total request count over a fixed calendar period — daily, weekly, or monthly. Once a key exhausts its quota, all subsequent requests return 403 with the message
Limit Exceededuntil the period resets. This is a billing and fair-use mechanism, not a real-time rate control.
A typical MCP server product tier structure might look like this:
| Tier | Rate (req/sec) | Burst | Daily Quota | Monthly Cost |
|---|---|---|---|---|
| Free | 10 | 20 | 1,000 | $0 |
| Starter | 100 | 200 | 10,000 | $29 |
| Pro | 500 | 1,000 | 100,000 | $99 |
| Enterprise | 1,000 | 5,000 | Unlimited (no quota) | Custom |
Important caveat: usage plans are a REST API feature only. The HTTP API (v2) supports route-level throttling (defaultRouteThrottling and per-route overrides in stage settings) but does not support API keys or usage plans at all. If you are on HTTP API and need per-client limits, you must implement them in Lambda or in front of API Gateway using a WAF rate-based rule.
Creating an API key and usage plan
The full workflow has four steps: create the usage plan, create the API key, link the key to the plan, and associate the plan with the API stage. Order matters — you cannot create the association before both the plan and the key exist.
# Step 1: Create the usage plan with throttle and daily quota
aws apigateway create-usage-plan \
--name "mcp-free-tier" \
--description "Free tier: 10 req/sec, 1000 req/day" \
--throttle rateLimit=10,burstLimit=20 \
--quota limit=1000,offset=0,period=DAY \
--api-stages apiId=abc123def,stage=prod
# The output includes the usage plan ID — save it
# USAGE_PLAN_ID=$(aws apigateway get-usage-plans --query "items[?name=='mcp-free-tier'].id" --output text)
# Step 2: Create an API key for a specific client
aws apigateway create-api-key \
--name "client-acme-corp" \
--description "ACME Corp free tier key" \
--enabled \
--generate-distinct-id
# The output includes the key ID and the key value — save both
# KEY_ID=$(aws apigateway get-api-keys --query "items[?name=='client-acme-corp'].id" --output text)
# Step 3: Link the API key to the usage plan (creates the UsagePlanKey resource)
# This is the step most often forgotten — the key and plan must be explicitly associated
aws apigateway create-usage-plan-key \
--usage-plan-id $USAGE_PLAN_ID \
--key-id $KEY_ID \
--key-type API_KEY
# Step 4: Verify the association
aws apigateway get-usage-plan-keys \
--usage-plan-id $USAGE_PLAN_ID
The stage association is created in Step 1 via --api-stages. You can also add stage associations after plan creation with update-usage-plan. Note that you can restrict a usage plan to specific methods within a stage — for example, only apply the quota to POST /mcp and not to GET /status — by specifying apiId=abc123def,stage=prod,throttle={path/POST/mcp={rateLimit=10,burstLimit=20}} in the api-stages parameter.
API key delivery: header vs query parameter
By default, API Gateway reads the key from the x-api-key HTTP header. You can configure a stage to also accept the key as a query parameter (?x-api-key=abc123) by enabling apiKeySource: HEADER (default) or apiKeySource: AUTHORIZER (custom authorizer injects the key). There is no native built-in query-parameter mode for API key source — if you need query-param delivery, use a Lambda authorizer that reads the query string and returns the API key context.
Warning: never encourage clients to pass API keys as query parameters unless absolutely necessary. Query parameters appear in access logs, CloudFront logs, S3 server-access logs for log archives, and CloudWatch Logs — all of which may be accessible to people who should not see client secrets. Headers do not appear in access logs by default.
Per-method throttle overrides
The usage plan sets a top-level throttle that applies to all methods on the associated stage. You can override this at the method level to protect expensive MCP tool endpoints without tightening limits on cheap informational endpoints. Method-level throttling uses update-stage patch operations, not update-usage-plan.
# Set a tighter rate limit on the expensive MCP tool execution endpoint
# Path format: /HTTP_METHOD~1path_with_slashes_escaped
aws apigateway update-stage \
--rest-api-id abc123def \
--stage-name prod \
--patch-operations \
op=replace,path=/POST~1mcp/throttling/rateLimit,value=50 \
op=replace,path=/POST~1mcp/throttling/burstLimit,value=100
# Set a more generous rate on the cheap status/health endpoint
aws apigateway update-stage \
--rest-api-id abc123def \
--stage-name prod \
--patch-operations \
op=replace,path=/GET~1status/throttling/rateLimit,value=1000 \
op=replace,path=/GET~1status/throttling/burstLimit,value=2000
# To remove a per-method override and revert to stage default:
aws apigateway update-stage \
--rest-api-id abc123def \
--stage-name prod \
--patch-operations \
op=remove,path=/POST~1mcp/throttling/rateLimit
The path encoding uses ~1 to represent a forward slash in the URL path, because the patch path itself uses / as a separator. So POST /mcp/tools/call becomes /POST~1mcp~1tools~1call/throttling/rateLimit.
Method-level throttles stack on top of usage plan throttles — the most restrictive limit wins. If your usage plan sets 100 req/sec for a key, and the method override sets 50 req/sec for POST /mcp, that client is limited to 50 req/sec on that method. The usage plan quota still applies across all methods combined.
Monitoring usage per API key
API Gateway emits CloudWatch metrics at multiple granularities. For per-key monitoring, the relevant metric is Count in the AWS/ApiGateway namespace with dimensions ApiName, Stage, and KeyId. The KeyId dimension is only populated when the request is authenticated via an API key — unauthenticated requests and requests that fail before key validation do not carry a KeyId.
# Get request count for a specific API key over the past day
aws cloudwatch get-metric-statistics \
--namespace AWS/ApiGateway \
--metric-name Count \
--dimensions \
Name=ApiName,Value=mcp-server-api \
Name=Stage,Value=prod \
Name=KeyId,Value=abc123keyid456 \
--start-time $(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 86400 \
--statistics Sum
To create an alarm that fires when a client approaches 80% of their daily quota, compute the 80% threshold and set a CloudWatch alarm on the Count metric summed over the day:
# For a key with 1,000 req/day quota, alarm at 800 requests
aws cloudwatch put-metric-alarm \
--alarm-name "key-acme-quota-warning" \
--alarm-description "ACME Corp approaching daily quota (80%)" \
--namespace AWS/ApiGateway \
--metric-name Count \
--dimensions \
Name=ApiName,Value=mcp-server-api \
Name=Stage,Value=prod \
Name=KeyId,Value=abc123keyid456 \
--statistic Sum \
--period 86400 \
--evaluation-periods 1 \
--threshold 800 \
--comparison-operator GreaterThanOrEqualToThreshold \
--alarm-actions arn:aws:sns:us-east-1:123456789012:mcp-quota-warnings \
--treat-missing-data notBreaching
For programmatic quota tracking (e.g., to show clients their remaining quota in your dashboard), use the API Gateway GetUsage API. This returns the actual usage and remaining allowance for a specific key within the current quota period:
# Get remaining quota for a specific key in the current period
aws apigateway get-usage \
--usage-plan-id $USAGE_PLAN_ID \
--key-id $KEY_ID \
--start-date $(date -u +%Y-%m-%d) \
--end-date $(date -u +%Y-%m-%d)
# Response includes:
# {
# "usagePlanId": "abc123",
# "startDate": "2026-10-31",
# "endDate": "2026-10-31",
# "items": {
# "abc123keyid456": [[850, 150]] # [used, remaining] for each day
# }
# }
The GetUsage response pairs are [used, remaining] for each day in the requested range. A remaining value of -1 means the key has no quota limit (enterprise tier with no cap). This API is rate-limited itself — avoid polling it more than once per minute per key.
CDK setup for usage plans
Usage plans and API keys are part of the aws-cdk-lib/aws-apigateway package and are only available on the RestApi construct. The HttpApi construct does not support usage plans, API keys, or quotas.
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as cdk from 'aws-cdk-lib';
const api = new apigateway.RestApi(this, 'McpServerApi', {
restApiName: 'mcp-server-api',
deployOptions: {
stageName: 'prod',
throttlingRateLimit: 10000, // account-level default, overridden by usage plan
throttlingBurstLimit: 5000,
metricsEnabled: true, // required for per-key CloudWatch metrics
},
});
// Free tier usage plan
const freePlan = api.addUsagePlan('FreeTierPlan', {
name: 'mcp-free-tier',
description: 'Free tier: 10 req/sec, 1000 req/day',
throttle: {
rateLimit: 10,
burstLimit: 20,
},
quota: {
limit: 1000,
period: apigateway.Period.DAY,
},
});
// Associate the usage plan with the prod stage
freePlan.addApiStage({
stage: api.deploymentStage,
});
// Create an API key for a specific client
const acmeKey = api.addApiKey('AcmeCorpKey', {
apiKeyName: 'client-acme-corp',
description: 'ACME Corp free tier key',
});
// Link the key to the usage plan (this creates the UsagePlanKey association)
freePlan.addApiKey(acmeKey);
// Pro tier plan — same API, different limits
const proPlan = api.addUsagePlan('ProTierPlan', {
name: 'mcp-pro-tier',
description: 'Pro tier: 500 req/sec, 100k req/day',
throttle: {
rateLimit: 500,
burstLimit: 1000,
},
quota: {
limit: 100000,
period: apigateway.Period.DAY,
},
});
proPlan.addApiStage({
stage: api.deploymentStage,
throttle: [
{
// Tighter limit on expensive tool execution endpoint
method: api.root.getResource('mcp')!.addMethod('POST'),
throttle: {
rateLimit: 200,
burstLimit: 400,
},
},
],
});
Note that metricsEnabled: true in the stage options is required for per-key CloudWatch metrics to appear. Without it, the Count metric exists but lacks the KeyId dimension breakdown.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
403 Forbidden with body {"message":"Forbidden"} |
API key is missing from the request (x-api-key header not sent), the key value is invalid, or the key is not associated with any usage plan on the stage being called |
Verify the key is sent in the x-api-key header (not query param unless configured). Confirm the key is enabled (aws apigateway get-api-key --api-key $KEY_ID --include-value) and that a UsagePlanKey resource exists linking it to the plan (get-usage-plan-keys --usage-plan-id $PLAN_ID) |
429 with {"message":"Limit Exceeded"} vs {"message":"Too Many Requests"} |
These are two distinct 429 causes: Limit Exceeded means the daily/weekly/monthly quota cap has been reached for this key. Too Many Requests means the real-time rate/burst throttle was hit |
For quota exhaustion, call get-usage to confirm remaining quota is 0, then either upgrade the plan or wait for period reset. For throttle, implement exponential backoff with jitter in the client. Quota resets at midnight UTC by default |
| API key created and associated with usage plan, but requests still return 403 | The usage plan is not associated with the API stage — the plan and stage must be linked via apiStages, and additionally the UsagePlanKey resource must exist. Both must be present; either one missing causes 403 |
Check stage associations: aws apigateway get-usage-plan --usage-plan-id $PLAN_ID and verify apiStages contains your API ID and stage name. If missing, add it: aws apigateway update-usage-plan --usage-plan-id $PLAN_ID --patch-operations op=add,path=/apiStages,value='apiId:stageName' |
CloudWatch Count metric showing aggregate totals but no breakdown by API key |
Stage-level metricsEnabled is false, or the stage was deployed before metrics were enabled — the KeyId dimension only appears when stage-level detailed metrics are on |
Enable metrics: aws apigateway update-stage --rest-api-id $API_ID --stage-name prod --patch-operations op=replace,path=/*/*/metrics/enabled,value=true. Note: this increases CloudWatch costs (~$3.50/month per million API calls for detailed metrics) |
| Clients receive 403 immediately after rotating an API key | The old key was deactivated (or deleted) before the new key was fully provisioned and the client updated its configuration — API Gateway key changes propagate in seconds but client key rotation may not be instantaneous | Implement key rotation with overlap: create the new key, associate it with the usage plan, distribute it to the client, wait for confirmation the client is using the new key (check CloudWatch Count metric for the new KeyId), then deactivate the old key. Allow at least 30 seconds between deactivating the old key and removing it from the plan |