Guide · AWS API Gateway
MCP Server API Gateway HTTP API — JWT authorizer, payload format v2, throttling, and CORS
API Gateway HTTP API (the "v2" API type) is the right choice for most MCP server endpoints: it is 70% cheaper than REST API ($1.00 per million vs $3.50), has ~10 ms less added latency, and includes a native JWT authorizer that validates tokens without any Lambda code. The trade-off is that HTTP API lacks some REST API features: no usage plans, no API key management, no per-method X-Ray tracing, no request validation, and no resource-based policies. For MCP tool endpoints where clients authenticate with a JWT (Cognito, Auth0, or any OIDC-compatible issuer), the native JWT authorizer is the fastest path — no Lambda invocation, no cold start, and no IAM policy document to maintain. The main operational pitfall is payload format: HTTP API defaults to format version 2.0, whose event shape is different from the 1.0 shape used by all REST API Lambdas — confusing any Lambda that was written for REST API and reused in an HTTP API without updating field access.
TL;DR
Use payloadFormatVersion: "2.0" (the HTTP API default) and access event.requestContext.http.method, event.rawPath, and event.rawQueryString — not event.httpMethod, event.path, event.queryStringParameters (still present but different shape). The native JWT authorizer needs only the OIDC issuer URL and audience list — no Lambda. For Lambda authorizers on HTTP API, use the simple response format ({ "isAuthorized": true }) unless you need per-route IAM policies. Throttling on HTTP API is set per-route via routeSettings, not per-method-stage as in REST API. CORS is configured at the API level and HTTP API handles OPTIONS preflight automatically — no Lambda for OPTIONS needed.
Payload format v2.0: field mapping for Lambda handlers
The most common migration mistake when moving from REST API to HTTP API is using REST API field names on the v2.0 event. The shapes are different at the top level. Lambda handlers must be updated to use the new field paths — or explicitly set payloadFormatVersion: "1.0" on the integration to keep the old shape (with a small latency overhead).
// Payload format v2.0 (HTTP API default) — event shape
const v2Event = {
version: "2.0",
routeKey: "POST /tools/invoke",
rawPath: "/tools/invoke",
rawQueryString: "sessionId=abc&debug=true",
headers: {
"content-type": "application/json",
"authorization": "Bearer eyJ..."
},
queryStringParameters: { // always present (empty object if none)
sessionId: "abc",
debug: "true"
},
pathParameters: { toolName: "search" }, // null if no path params
body: '{"input":"query"}', // always a string (not parsed)
isBase64Encoded: false,
requestContext: {
accountId: "123456789",
apiId: "abc123",
domainName: "abc123.execute-api.us-east-1.amazonaws.com",
http: {
method: "POST", // v2: here, not event.httpMethod
path: "/tools/invoke", // v2: here, not event.path
protocol: "HTTP/1.1",
sourceIp: "1.2.3.4",
userAgent: "..."
},
requestId: "...",
routeKey: "POST /tools/invoke",
stage: "prod",
time: "18/Sep/2026:10:00:00 +0000",
timeEpoch: 1726653600000
},
// JWT claims available here if a JWT authorizer is attached
requestContext: {
authorizer: {
jwt: {
claims: { sub: "user123", email: "user@example.com" },
scopes: ["openid", "tools:invoke"]
}
}
}
};
// v2.0-compatible Lambda handler
export const handler = async (event) => {
// v2: use event.requestContext.http.method (NOT event.httpMethod)
const method = event.requestContext.http.method;
const path = event.rawPath;
const body = event.body ? JSON.parse(event.body) : {};
// JWT claims from native authorizer (no Lambda code to verify token)
const userId = event.requestContext.authorizer?.jwt?.claims?.sub;
return {
statusCode: 200,
// v2 response: headers is an object (same as v1); body must be a string
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ status: 'ok', userId })
};
};
Native JWT authorizer: OIDC configuration
The JWT authorizer validates the token's signature, expiry, issuer, and audience without a Lambda. API Gateway fetches the OIDC discovery document from {issuer}/.well-known/openid-configuration and caches the JWKS for 2 hours. The authorizer checks: exp not in the past, iss matches the configured issuer, and aud (or client_id for some providers) contains the configured audience. Token claims are forwarded to the Lambda in event.requestContext.authorizer.jwt.claims.
// AWS CDK — HTTP API with JWT authorizer (Cognito example)
import { HttpApi, HttpMethod, HttpJwtAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2';
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
const userPool = new cognito.UserPool(this, 'Users');
const userPoolClient = userPool.addClient('ApiClient');
const jwtAuthorizer = new HttpJwtAuthorizer('JwtAuth', userPool.userPoolProviderUrl, {
jwtAudience: [userPoolClient.userPoolClientId]
});
const httpApi = new HttpApi(this, 'ToolsApi', {
corsPreflight: {
allowHeaders: ['Authorization', 'Content-Type'],
allowMethods: [CorsHttpMethod.POST, CorsHttpMethod.GET, CorsHttpMethod.OPTIONS],
allowOrigins: ['https://app.example.com'],
maxAge: Duration.hours(1)
}
});
httpApi.addRoutes({
path: '/tools/invoke',
methods: [HttpMethod.POST],
integration: new HttpLambdaIntegration('InvokeTool', invokeToolFn),
authorizer: jwtAuthorizer
});
// For non-CDK: Terraform / CloudFormation JwtConfiguration on AWS::ApiGatewayV2::Authorizer
// Properties:
// AuthorizerType: JWT
// IdentitySource: $request.header.Authorization
// JwtConfiguration:
// Audience: [ !Ref UserPoolClientId ]
// Issuer: !Sub https://cognito-idp.${AWS::Region}.amazonaws.com/${UserPool}
// For Auth0 or other OIDC providers:
// Issuer: https://your-domain.auth0.com/
// Audience: ["https://api.example.com"] # your API identifier in Auth0
HTTP API JWT authorizers do not support aud arrays by itself for providers that put audience in client_id (like some OAuth 2.0 providers). In those cases, use a Lambda authorizer with the simple response format to validate the token and return { isAuthorized: true }. The Lambda authorizer on HTTP API differs from REST API: there is no TOKEN type — only REQUEST type — and the default cache key is the full identitySource expression (typically $request.header.Authorization).
Per-route throttling and default stage settings
HTTP API throttling is configured in two layers: default stage-level throttle (applies to all routes) and per-route overrides via routeSettings. Both specify throttlingBurstLimit (maximum concurrent requests in a burst) and throttlingRateLimit (sustained requests per second). When throttled, API Gateway returns 429 Too Many Requests with a Retry-After header.
// AWS CDK — per-route throttle settings on HTTP API
const httpApi = new HttpApi(this, 'ToolsApi', {
defaultAuthorizationScopes: ['openid'],
// Default throttle for all routes
throttle: {
burstLimit: 500,
rateLimit: 100 // sustained req/sec across all routes
}
});
// Per-route override: stricter limit on expensive tool invocations
const stage = httpApi.defaultStage?.node.defaultChild as CfnStage;
stage.addPropertyOverride('RouteSettings', {
'POST /tools/invoke': {
ThrottlingBurstLimit: 50,
ThrottlingRateLimit: 10 // max 10 req/sec for tool invocations
},
'GET /tools/status': {
ThrottlingBurstLimit: 1000,
ThrottlingRateLimit: 500 // status checks are cheap — allow more
}
});
// Response when throttled — Lambda does NOT receive the request:
// HTTP 429 Too Many Requests
// { "message": "Too Many Requests" }
// Retry-After: 1
//
// Clients should implement exponential backoff with jitter starting at 1s.
// MCP client library should surface 429s as a retryable ToolError.
HTTP API vs REST API decision matrix
| Feature | HTTP API (v2) | REST API (v1) |
|---|---|---|
| Price per million requests | $1.00 | $3.50 |
| Added latency | ~10 ms | ~30 ms |
| Native JWT authorizer | Yes (no Lambda) | No (Lambda required) |
| Lambda authorizer response | Simple (isAuthorized) or IAM policy | IAM policy only |
| CORS support | First-class (no OPTIONS Lambda) | Manual OPTIONS routes or mock integration |
| Usage plans + API keys | No | Yes |
| Request validation | No | Yes (JSON Schema) |
| Per-method X-Ray tracing | No (Lambda-side only) | Yes |
| VPC Link target | ALB or NLB | NLB only |
| WebSocket support | No (separate API type) | No (separate API type) |
| Maximum integration timeout | 30 s (not configurable) | 29 s (configurable) |
| Payload format default | v2.0 | v1.0 |
Failure modes reference
| Failure | Symptom | Fix |
|---|---|---|
| Lambda reads event.httpMethod on v2.0 payload | event.httpMethod is undefined; route logic falls through to default/error case | Use event.requestContext.http.method for HTTP API v2.0; or set payloadFormatVersion: "1.0" on the Lambda integration to keep the old shape |
| JWT authorizer issuer URL with trailing slash mismatch | 401 Unauthorized — iss claim doesn't match configured issuer | The iss claim in the JWT must exactly match the Issuer URL in the authorizer config — trailing slash matters; check the token's iss claim with jwt.io |
| CORS preflight fails after adding Authorization header | Browser preflight OPTIONS returns 403; real request never sent | Add Authorization to corsPreflight.allowHeaders in the HTTP API CORS config; HTTP API CORS does not automatically include Authorization |
| 429 not retried by MCP client | Tool call fails permanently on spike; user sees error instead of delayed result | Implement exponential backoff with jitter for 429 responses in the MCP client layer; use Retry-After header value as the initial backoff duration |
| Lambda authorizer returns IAM policy on HTTP API | Policy is ignored; all requests pass through regardless of policy effect | HTTP API Lambda authorizer with simple response format: return { isAuthorized: true/false }; IAM policy format is only used when authorizerPayloadFormatVersion is "1.0" and type is REQUEST |
| VPC Link to ALB fails health check | 502 Bad Gateway on all routes through VPC Link | HTTP API VPC Link requires the ALB listener and target group to be healthy; check ALB target health; ensure security group allows traffic from the VPC Link's internal CIDR |