AWS Serverless

Amazon API Gateway Hands-On: REST vs HTTP APIs, Authorizers & Throttling

Every serverless API you build eventually needs a front door — something that terminates TLS, checks who is calling, refuses to be hammered, shapes the request, and hands it to your code. On AWS that front door is Amazon API Gateway, and the single most consequential decision you will make is which of its three flavors to use: a REST API (feature-rich, VTL transforms, API keys, caching, private endpoints), an HTTP API (cheaper, faster, JWT-native, the modern default for plain Lambda proxying), or a WebSocket API (stateful, two-way, for chat and live feeds). Choose the wrong one and you either pay 3.5× per request for features you never touch, or you box yourself into a flavor that cannot do the request transformation your legacy backend needs.

This is not a “click Create API” tutorial. API Gateway is a stack of gates — custom domain, WAF, authorizer, throttle bucket, request validator, mapping template, integration — and each gate has its own configuration surface, its own quota, and its own status code when it says no. A 403 can mean five completely different things depending on which gate emitted it. A 502 almost always means your Lambda returned the wrong shape. A 504 means you crossed the 29-second integration ceiling. Knowing which gate bit, from the status code and one log line, is the difference between a five-minute fix and a two-hour guess.

By the end you will be able to pick REST vs HTTP from a feature-and-cost matrix instead of a coin flip, wire every authorizer type (IAM SigV4, Cognito user pools, Lambda token/request, and HTTP-API JWT), throttle a spike gracefully with usage plans and API keys, reshape requests with VTL, front the API with a custom domain and WAF, and read the access log to find any failing hop. You will build two working APIs by hand — an HTTP API with a JWT authorizer and a REST API with an API-key usage plan, throttling and a mapping template — with aws CLI and Terraform, test authorized and throttled calls, then tear it all down.

What problem this solves

Without a managed API front door you are writing, in every service, the same undifferentiated plumbing: TLS termination, request parsing, auth token validation, rate limiting, per-client quotas, request/response logging, CORS handling, and a routing layer that maps URLs to handlers. That code is security-critical, tedious, and identical across teams — and when you hand-roll it on EC2 or containers you also own patching it, scaling it, and paying for it to idle overnight. Teams that skip the front door end up with each Lambda parsing its own JWTs, no consistent throttle, and a rate-limit story that is “hope nobody notices.”

API Gateway externalizes all of that into configuration. It terminates TLS at a custom domain with an ACM certificate, validates a JWT or a SigV4 signature or an API key before your code runs, throttles per-account, per-stage, per-method and per-client, validates the request body against a schema, transforms it with a template, invokes your Lambda or HTTP backend, logs every hop, and traces it with X-Ray — all billed per request, scaling from zero to tens of thousands of requests per second with no capacity to manage. The pain it removes is real, but it introduces its own: the wrong flavor is expensive, the proxy response shape is unforgiving, the 29-second timeout is a hard wall, and CORS-on-errors is a classic silent failure.

Here is the full request path this article covers, gate by gate, with what each gate does and its characteristic failure:

# Gate What it does Config surface Characteristic failure
1 Route 53 + custom domain DNS + TLS at your own hostname ACM cert, base-path mapping 403 from wrong base-path / cert region
2 WAF (REST only) Blocks bots, rate-limits, filters WebACL on the stage Silent 403 with no execution log
3 Stage Deployed snapshot; where logging/throttle/cache attach Stage settings, stage variables Stage variable ${} unresolved
4 Authorizer Verifies caller identity JWT / Cognito / Lambda / IAM 401 (no/expired token) or 403 (denied)
5 Throttle + usage plan Rate/burst/quota per account, stage, method, client Method throttle, usage plan 429 Too Many Requests
6 Request validator Rejects bad body/params Validator + model (JSON Schema) 400 Bad Request Body
7 Mapping template (VTL) Reshapes request/response (REST non-proxy) Integration request/response 500 from template error
8 Integration Invokes Lambda / HTTP / AWS service Proxy or non-proxy 502 (bad shape) / 504 (timeout)
9 Access + execution logs, X-Ray Observability of every hop Stage settings Blind debugging with logs off

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should have the AWS CLI v2 configured with credentials for a sandbox account, permission to create API Gateway, Lambda, IAM and (optionally) Cognito resources, and one working Lambda runtime (this article uses Node.js 20). Comfort with JSON, HTTP verbs, status codes and JWTs helps; no prior API Gateway experience is assumed. Terraform ≥ 1.5 with the aws provider is used for the IaC snippets.

This article is the front-door deep dive of the AWS serverless track. It sits one layer below the reference build in AWS Serverless Web Application Architecture: CloudFront, API Gateway, Lambda and DynamoDB End to End, goes far deeper on the API tier than the load-balancer comparison in AWS Load Balancers and API Gateway: ALB, NLB and API Gateway Compared, and feeds the pure event-driven work in AWS Lambda Patterns: Event-Driven Functions That Scale to Zero. If you have not yet shipped a function, do Your First AWS Lambda Function: A Hands-On Walkthrough first — this article assumes you can deploy a Lambda and read its logs.

Core concepts

Six mental models carry the whole service.

API Gateway is a pipeline of request/response phases, not a single hop. Every REST request passes through four phases: the method request (auth, validation, API key), the integration request (mapping template transforms it for the backend), the integration (the backend runs), and the integration response / method response (a template transforms the result back). Each phase can succeed or fail independently, and the phase decides the status code. HTTP APIs collapse this to a leaner path (route → authorizer → integration) with no VTL, which is exactly why they are faster and cheaper.

A stage is the only thing that is actually live. You build an API definition, then create a deployment (an immutable snapshot), then point a stage (prod, dev, $default) at that deployment. Throttling, logging, caching, WAF, X-Ray and stage variables all attach to the stage, not the API. Forgetting to redeploy after a change is the single most common “my fix isn’t live” bug on REST APIs — HTTP APIs can auto-deploy to sidestep it.

Authorization happens at the door, before your code. An authorizer verifies the caller and either lets the request through or returns 401/403 without invoking your integration. A JWT authorizer (HTTP API) checks a token’s signature, issuer, audience and expiry statelessly against a JWKS endpoint. A Lambda authorizer runs your code to return an IAM-style policy, cached by identity source. IAM (SigV4) authorization requires the caller to sign the request with AWS credentials. Your business logic never sees a raw credential.

Proxy integration trades control for simplicity. With Lambda proxy (AWS_PROXY), API Gateway passes the whole request to your function as a fixed JSON event and expects a fixed JSON response { statusCode, headers, body } back — no templates, but your function owns the entire HTTP contract. With non-proxy, you write VTL mapping templates to translate between HTTP and whatever your backend wants. Proxy is the default for new work; non-proxy is for legacy backends and fine-grained transformation.

Throttling is layered and additive. The account has a region-wide bucket (10,000 rps steady-state, 5,000 burst by default). On top of that you set stage and method limits, and per-client limits through usage plans + API keys. A request must pass every applicable bucket; the tightest one that trips returns 429. This is capacity planning as quota planning: know each ceiling before launch, not during one.

Everything is observable if you turn it on — and blind if you don’t. Access logs record one line per request ($context variables: status, latency, which authorizer, integration error). Execution logs record the internal phase-by-phase trace. X-Ray stitches the API-to-integration latency. None are on by default, and REST execution/access logging needs a one-time account-level CloudWatch Logs role ARN — the classic “why are there no logs” gotcha.

The moving parts, pinned down:

Term One-line definition Where set Why it matters
Resource A URL path node (/items, /items/{id}) REST API The routing tree
Method An HTTP verb on a resource (GET /items) REST API Where auth/validation attach
Route HTTP API’s VERB /path unit HTTP API Leaner equivalent of method+resource
Integration The backend binding for a method/route Method / route Proxy vs non-proxy; the target
Stage A named deployment of the API API Live URL; throttle/log/cache anchor
Deployment An immutable snapshot of the definition REST API REST must redeploy to go live
Stage variable ${stageVariables.x} key-value per stage Stage Point one stage at a different backend
Authorizer Pre-integration identity check API Emits 401/403 before your code
Usage plan Rate/burst/quota bundle tied to API keys REST API Per-client throttling and metering
API key x-api-key client identifier REST API Associates a caller with a usage plan
Mapping template VTL transform of request/response REST integration Non-proxy request/response shaping
Model A JSON Schema for request validation REST API Basis for 400-on-bad-body
Gateway response Customizable canned error (4XX/5XX) API Where you add CORS headers to errors

The request lifecycle, phase by phase (REST)

Phase What runs Can emit Your control point
Method request Auth, API-key check, request validation 401, 403, 400, 429 Authorizer, validator, apiKeyRequired
Integration request Request mapping template (non-proxy) 500 (template error) VTL $input/$context mapping
Integration Backend (Lambda/HTTP/AWS) executes 502, 504 Timeout (≤29 s), backend correctness
Integration response Response mapping + status override 500 (template error) VTL, selectionPattern regex
Method response Final headers/status to the client 200/4xx/5xx Declared response models/headers

The three flavors: REST vs HTTP vs WebSocket

API Gateway is really three products sharing a console. Getting the choice right up front saves both money and rework.

Full feature matrix

Capability REST API HTTP API WebSocket API
Best for Feature-rich public/partner APIs Simple Lambda/HTTP proxying Two-way, stateful (chat, live)
Protocol Request/response HTTP Request/response HTTP Persistent WebSocket
Price / million req $3.50 (tiered to $1.51) $1.00 (to $0.90) $1.00 msgs + $0.25/M conn-min
Latency overhead Higher (VTL pipeline) Lowest (~60% less) n/a
Lambda proxy Yes Yes Yes (per route)
Non-proxy + VTL mapping Yes No Limited (templates on routes)
HTTP proxy integration Yes Yes No
AWS-service integration Yes (many services) Yes (subset via SDK) Yes
Mock integration Yes No No
Private (VPC) endpoint Yes No No
Edge-optimized endpoint Yes No (regional only) No
API keys + usage plans Yes No No
Per-client throttling Yes (usage plans) No No
Request validation (models) Yes No No
Response caching Yes (0.5–237 GB) No No
JWT authorizer No (use Cognito authorizer) Yes (any OIDC) No
Cognito user pool authorizer Yes Via JWT authorizer No
Lambda authorizer Yes (token + request) Yes (request, simple resp.) Yes
IAM (SigV4) auth Yes Yes Yes
WAF Yes No (front with CloudFront) No
Custom domain + mTLS Yes (mTLS on regional) Yes (mTLS) Yes
Auto-deploy No (explicit deployment) Yes (optional) No
Access/execution logs Both Access only Both
X-Ray tracing Yes Yes Yes
Payload format 1.0 1.0 or 2.0 n/a
Max integration timeout 29 s (raisable via quota) 29 s (raisable via quota) 29 s

REST vs HTTP cost, side by side

At 1 KB responses, no data-transfer surprises, us-east-1 list price:

Monthly requests REST @ $3.50/M HTTP @ $1.00/M You save with HTTP
1 million $3.50 $1.00 $2.50 (71%)
10 million $35.00 $10.00 $25.00
100 million $350.00 $100.00 $250.00
333 million $1,165.50 $333.00 $832.50
1 billion ~$2,171 (tiered) ~$933 (tiered) ~$1,238

REST tiers down (first 333M at $3.50, next to 667M at $2.80, next to 19B at $2.38, above at $1.51); HTTP is $1.00 for the first 300M then $0.90. The gap is why HTTP is the default for plain proxying — but note what you give up above: no usage plans, no caching, no VTL, no WAF, no request validation.

Which flavor — the decision table

If you need… Pick Because
Cheapest Lambda/HTTP proxy, JWT auth HTTP API 71% cheaper, lowest latency, JWT-native
API keys + per-client quotas (metering, partners) REST API Usage plans are REST-only
Response caching to shield the backend REST API Caching is REST-only
VTL request/response transformation REST API HTTP has no mapping templates
Request body validation against a schema REST API Models/validators are REST-only
A private API reachable only inside a VPC REST API PRIVATE endpoint type is REST-only
WAF directly on the API REST API WAF attaches to REST stages only
An edge-optimized global endpoint REST API Or put CloudFront in front of HTTP
Two-way push (chat, tickers, notifications) WebSocket API Persistent connection, routes
Simple internal microservice front door HTTP API Fast, cheap, less to configure

Resources, methods and integrations

Integration types, end to end

Integration type REST value What it does Templates? Typical use
Lambda proxy AWS_PROXY Passes whole request to Lambda, expects fixed response shape No New serverless APIs (default)
Lambda (non-proxy) AWS Invokes Lambda; you map request and response with VTL Yes Fine-grained shaping, legacy events
HTTP proxy HTTP_PROXY Forwards request as-is to an HTTP(S) URL No Fronting an existing HTTP backend
HTTP (non-proxy) HTTP Forwards to HTTP URL with VTL mapping Yes Reshape before/after an HTTP backend
AWS service AWS Calls an AWS service action directly (no Lambda) Yes S3 upload, SQS SendMessage, DynamoDB PutItem
Mock MOCK Returns a response from a template, no backend Yes Stubs, CORS preflight, health checks
VPC link (private) HTTP_PROXY + VpcLink Reaches a private NLB/ALB in your VPC Proxy/no Private microservices behind API GW

Lambda proxy: the event shape (payload 1.0, REST)

Your handler receives this exact object:

Field Type Contains
resource string The matched resource path (/items/{id})
path string The actual request path (/items/42)
httpMethod string GET, POST, …
headers map Single-valued headers
multiValueHeaders map[list] Repeated headers
queryStringParameters map ?a=1{a:"1"} (null if none)
multiValueQueryStringParameters map[list] Repeated query params
pathParameters map {id:"42"}
stageVariables map The stage’s variables
requestContext object requestId, identity, authorizer claims, stage
body string Raw body (string, even for JSON)
isBase64Encoded boolean True for binary bodies

Lambda proxy: the response shape (get this wrong = 502)

Field Required Rule
statusCode Yes (REST 1.0) Integer HTTP status
headers No { "Content-Type": "application/json" }
multiValueHeaders No For repeated response headers
body Yes if present must be a string JSON must be JSON.stringify-ed
isBase64Encoded No True when body is base64 binary

The single most common REST proxy bug: returning a raw object as body instead of a string, or omitting statusCode. API Gateway cannot serialize it and returns 502 Bad Gateway. HTTP APIs (payload 2.0) are forgiving — return a bare object and they default statusCode to 200 and stringify — but a returned object with a numeric body still 502s.

Proxy vs non-proxy — what you trade

Aspect Lambda proxy (AWS_PROXY) Lambda non-proxy (AWS)
Request mapping None — full event to Lambda VTL template you write
Response mapping None — Lambda owns status/headers VTL template + selectionPattern
Coupling Lambda owns the HTTP contract API Gateway owns it
CORS on success Lambda must add headers Method response declares them
Error status mapping Lambda sets statusCode selectionPattern regex on error
Speed to build Fastest Slower (templates)
Best when New code you control Legacy events, strict shaping

HTTP API payload format 2.0 vs 1.0

Property 1.0 (REST + HTTP opt-in) 2.0 (HTTP default)
version field absent / "1.0" "2.0"
Method location httpMethod requestContext.http.method
Path path rawPath + routeKey
Multi-value headers multiValueHeaders Comma-joined in headers
Cookies in headers dedicated cookies array
Response shortcut must return full shape bare object → 200 + stringified
Query string queryStringParameters queryStringParameters + rawQueryString

Wiring a method (REST) and a route (HTTP)

REST needs the four-step method chain; HTTP needs one route:

# REST: create resource, method, proxy integration, deploy
REST_ID=$(aws apigateway create-rest-api --name orders-rest \
  --endpoint-configuration types=REGIONAL --query id --output text)
ROOT=$(aws apigateway get-resources --rest-api-id "$REST_ID" \
  --query 'items[0].id' --output text)
RES=$(aws apigateway create-resource --rest-api-id "$REST_ID" \
  --parent-id "$ROOT" --path-part items --query id --output text)
aws apigateway put-method --rest-api-id "$REST_ID" --resource-id "$RES" \
  --http-method GET --authorization-type NONE --api-key-required
aws apigateway put-integration --rest-api-id "$REST_ID" --resource-id "$RES" \
  --http-method GET --type AWS_PROXY --integration-http-method POST \
  --uri "arn:aws:apigateway:$AWS_REGION:lambda:path/2015-03-31/functions/$FN_ARN/invocations"
aws apigateway create-deployment --rest-api-id "$REST_ID" --stage-name prod
# HTTP: one-shot quick-create wires route + integration + $default stage
aws apigatewayv2 create-api --name orders-http --protocol-type HTTP \
  --target "$FN_ARN"        # auto-creates $default route + auto-deploy stage
# Terraform: HTTP API route to a Lambda proxy
resource "aws_apigatewayv2_api" "http" {
  name          = "orders-http"
  protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "lambda" {
  api_id                 = aws_apigatewayv2_api.http.id
  integration_type       = "AWS_PROXY"
  integration_uri        = aws_lambda_function.api.invoke_arn
  payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "get_items" {
  api_id    = aws_apigatewayv2_api.http.id
  route_key = "GET /items"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}

Stages, deployments and stage variables

Stage settings that matter

Setting REST HTTP Purpose
Throttle rate / burst Yes Yes (default route) Requests/sec + burst per stage
Access logging Yes Yes One $context line per request
Execution logging (OFF/ERROR/INFO) Yes No Phase-by-phase internal trace
Detailed CloudWatch metrics Yes Yes 4XX/5XX/Latency per method/route
Cache Yes No TTL response cache (REST)
X-Ray active tracing Yes Yes End-to-end trace
WAF WebACL Yes No Attach a WebACL
Stage variables Yes Yes Per-stage config
Auto-deploy No Yes Push changes without a deployment
Client certificate Yes No Backend can verify API GW

Stage variables — one API, many backends

A stage variable is a name=value on the stage, referenced as ${stageVariables.name} in an integration URI, a Lambda alias, or a mapping template. The classic use: prod points at the :prod Lambda alias, dev at :dev, from one API definition.

Use case Reference Notes
Lambda alias per stage ...:function:api:${stageVariables.lambdaAlias} Add lambda:InvokeFunction permission per alias
HTTP backend per stage https://${stageVariables.host}/orders Full or partial URI substitution
Feature flag in VTL #if($stageVariables.debug == "on") Toggle behavior without redeploy
Auth toggle authorizer ARN via variable Different authorizer per environment

Gotcha: a stage variable used in a Lambda integration URI that has no matching lambda:InvokeFunction permission for that alias produces a 500 with Invalid permissions on Lambda function. And an unresolved ${stageVariables.x} (variable not set on the stage) surfaces as a 500 — always confirm the variable exists on the stage you called.

Canary releases (REST)

A canary shifts a percentage of stage traffic to a new deployment while the rest stays on the current one — same URL, split at the stage.

Canary setting Values Effect
percentTraffic 0.0–100.0 Share of requests to the canary deployment
deploymentId a deployment The new snapshot under test
stageVariableOverrides map Canary-only stage variables
useStageCache true/false Whether canary shares the stage cache
aws apigateway update-stage --rest-api-id "$REST_ID" --stage-name prod \
  --patch-operations op=replace,path=/canarySettings/percentTraffic,value=10
# promote when metrics look good:
aws apigateway update-stage --rest-api-id "$REST_ID" --stage-name prod \
  --patch-operations op=copy,from=/canarySettings/deploymentId,path=/deploymentId \
                     op=remove,path=/canarySettings

Custom domains, endpoint types and ACM

Endpoint types (REST)

Endpoint type Where it lives Best for ACM cert region
Edge-optimized CloudFront-fronted, global Geographically dispersed clients us-east-1 (always)
Regional In the API’s region Same-region clients, custom CDN, mTLS The API’s region
Private Interface VPC endpoint only Internal-only APIs n/a (no public domain)

The number-one custom-domain 403/handshake failure: putting the ACM cert in the wrong region. Edge-optimized custom domains need the cert in us-east-1 regardless of where the API runs; regional domains need it in the same region as the API. HTTP APIs are regional only.

Custom domain components

Component What it is Gotcha
Domain name api.example.com you own Must have a validated ACM cert
Base-path mapping Maps a path (/v1) to an API+stage Missing/empty mapping → 403 at the domain
DNS record Alias/CNAME to the API GW target Point at the distribution/regional target, not the execute-api URL
Security policy Min TLS 1.2 Default and recommended
mTLS Client-cert trust store in S3 Regional REST + HTTP only
# Regional custom domain + base-path mapping (REST)
aws apigateway create-domain-name --domain-name api.example.com \
  --regional-certificate-arn "$CERT_ARN" \
  --endpoint-configuration types=REGIONAL --security-policy TLS_1_2
aws apigateway create-base-path-mapping --domain-name api.example.com \
  --rest-api-id "$REST_ID" --stage prod --base-path v1
resource "aws_apigatewayv2_domain_name" "api" {
  domain_name = "api.example.com"
  domain_name_configuration {
    certificate_arn = aws_acm_certificate.api.arn   # same region for HTTP API
    endpoint_type   = "REGIONAL"
    security_policy  = "TLS_1_2"
  }
}
resource "aws_apigatewayv2_api_mapping" "v1" {
  api_id      = aws_apigatewayv2_api.http.id
  domain_name = aws_apigatewayv2_domain_name.api.id
  stage       = aws_apigatewayv2_stage.prod.id
  api_mapping_key = "v1"
}

Authorization: five ways to say who you are

The authorizer matrix

Authorizer Flavors Verifies Emits on fail Caching
IAM (SigV4) REST, HTTP, WS AWS SigV4 signature + execute-api:Invoke 403 n/a
Cognito user pools REST ID/access token from a pool + scopes 401 n/a
Lambda authorizer (TOKEN) REST, WS A single header token, your code returns a policy 401/403 By token, 0–3600 s
Lambda authorizer (REQUEST) REST, HTTP, WS Headers/query/stage/context, returns policy or simple bool 401/403 By identity sources
JWT authorizer HTTP Any OIDC JWT (Cognito, Auth0, Okta): iss, aud, exp, scopes 401 Stateless (JWKS cached)

IAM (SigV4) authorization

Set authorization-type AWS_IAM on the method; the caller signs with AWS credentials, and API Gateway checks an execute-api:Invoke allow. Best for service-to-service and internal tools where callers already hold IAM credentials.

aws apigateway update-method --rest-api-id "$REST_ID" --resource-id "$RES" \
  --http-method GET --patch-operations op=replace,path=/authorizationType,value=AWS_IAM
# caller must sign; e.g. awscurl / SigV4 SDK:
awscurl --service execute-api --region "$AWS_REGION" "https://$REST_ID.execute-api.$AWS_REGION.amazonaws.com/prod/items"

Cognito user pool authorizer (REST)

Field Value Note
providerARNs the user pool ARN(s) Multiple pools allowed
Token source Authorization header Send the ID or access token
authorizationScopes OAuth scopes on the method Requires the access token if set
Validation signature, exp, token_use No code to run

Lambda authorizers — token vs request

Aspect TOKEN authorizer REQUEST authorizer
Input to your Lambda One header value (e.g. Authorization) Headers, query, path, stage, context
Cache key The token string Concatenated identity sources
identitySource method.request.header.Authorization One or more request params
HTTP API support No Yes (with simple responses)
Returns IAM policy (+ optional context) IAM policy, or {isAuthorized, context}
Result TTL authorizerResultTtlInSeconds 0–3600 (default 300) same

A Lambda authorizer returns an IAM policy document plus an optional context map that flows to $context.authorizer.<key> (and into a proxy event’s requestContext.authorizer). The Resource in the policy can be a wildcard so the cached Allow covers sibling methods; scope it tighter for least privilege.

// REQUEST Lambda authorizer (HTTP API simple response)
exports.handler = async (event) => {
  const token = (event.headers.authorization || "").replace("Bearer ", "");
  const ok = token === process.env.SHARED_SECRET;      // demo: verify a JWT for real
  return { isAuthorized: ok, context: { tenant: "acme" } };
};

JWT authorizer (HTTP API)

The modern default: no code, stateless, works with any OIDC issuer.

Field Example Meaning
issuer https://cognito-idp.<region>.amazonaws.com/<poolId> JWKS is fetched from here
audience the app client ID / API identifier Must match the token aud/client_id
identitySource $request.header.Authorization Where the bearer token is
Scopes on the route (authorizationScopes) Optional per-route OAuth scopes
aws apigatewayv2 create-authorizer --api-id "$HTTP_ID" --name jwt-auth \
  --authorizer-type JWT --identity-source '$request.header.Authorization' \
  --jwt-configuration Audience="$APP_CLIENT_ID",Issuer="https://cognito-idp.$AWS_REGION.amazonaws.com/$POOL_ID"
resource "aws_apigatewayv2_authorizer" "jwt" {
  api_id           = aws_apigatewayv2_api.http.id
  authorizer_type  = "JWT"
  identity_sources = ["$request.header.Authorization"]
  name             = "jwt-auth"
  jwt_configuration {
    audience = [var.app_client_id]
    issuer   = "https://cognito-idp.${var.region}.amazonaws.com/${var.pool_id}"
  }
}

Request validation and VTL mapping templates (REST)

Request validation

A request validator checks the request before integration, returning 400 on failure with no backend cost.

Validator Checks Set on
Validate body Body against a model (JSON Schema) Method request
Validate query string parameters and headers Required params/headers present Method request
Validate body, query string parameters, and headers Both Method request
VAL=$(aws apigateway create-request-validator --rest-api-id "$REST_ID" \
  --name body-and-params --validate-request-body --validate-request-parameters \
  --query id --output text)
aws apigateway put-method --rest-api-id "$REST_ID" --resource-id "$RES" \
  --http-method POST --authorization-type NONE \
  --request-validator-id "$VAL" --request-models application/json=OrderModel

VTL: the variables you actually use

Variable Returns Example
$input.body Raw request body string logging, passthrough
$input.json('$.field') JSON-path value as JSON $input.json('$.orderId')
$input.path('$') JSON-path value as an object iterate with #foreach
$input.params('name') Path/query/header param $input.params('id')
$context.requestId Unique request id trace correlation
$context.identity.sourceIp Caller IP logging, allow-listing
$context.authorizer.<key> Authorizer context value tenant/user propagation
$context.stage Stage name environment branching
$stageVariables.<key> Stage variable backend selection
$util.escapeJavaScript() Escapes a string for JSON safe body building
$util.parseJson() Parses a JSON string reshape nested payloads
$util.base64Encode() Base64 binary handling
## Integration request template: HTTP body → SQS SendMessage form
Action=SendMessage&MessageBody=$util.urlEncode($input.body)&MessageAttribute.1.Name=tenant&MessageAttribute.1.Value.StringValue=$context.authorizer.tenant&MessageAttribute.1.Value.DataType=String
passthroughBehavior Meaning
WHEN_NO_MATCH Pass raw body through when no template matches the Content-Type
WHEN_NO_TEMPLATES Pass through only if no templates defined
NEVER Reject unmatched Content-Type with 415 Unsupported Media Type

Throttling, usage plans and API keys

The four throttle layers (they stack)

Layer Scope Default How to set Trips with
Account Region-wide, all APIs 10,000 rps steady / 5,000 burst Service Quotas increase 429
Stage One stage inherits account Stage throttle rate/burst 429
Method One method on a stage inherits stage Per-method override 429
Usage plan (per key) One API key none until set Usage plan rate/burst/quota 429

A request must satisfy every applicable bucket; the tightest one that empties returns 429 Too Many Requests with x-amzn-ErrorType: ThrottlingException (or LimitExceededException when a usage-plan quota is exhausted). Throttling is a token bucket: burst is the bucket size (max concurrent), rate is the refill per second.

Usage plans + API keys (REST only)

Setting What it does Example
throttle.rateLimit Steady rps for keys on the plan 100
throttle.burstLimit Bucket size 200
quota.limit + period Requests per DAY/WEEK/MONTH 1,000,000 / MONTH
API key x-api-key header value (20–128 chars) client identifier, not auth
apiKeyRequired on method Enforces the key 403 if missing when required
apiStages Which API+stage the plan applies to orders-rest:prod

An API key is an identifier and metering handle, not a security control — never use it as authentication. Pair it with a real authorizer. A key sent to a method whose apiKeyRequired is false is simply ignored (no metering, no throttle by plan).

KEY_ID=$(aws apigateway create-api-key --name acme --enabled --query id --output text)
PLAN=$(aws apigateway create-usage-plan --name bronze \
  --throttle rateLimit=100,burstLimit=200 \
  --quota limit=1000000,period=MONTH \
  --api-stages apiId="$REST_ID",stage=prod --query id --output text)
aws apigateway create-usage-plan-key --usage-plan-id "$PLAN" \
  --key-id "$KEY_ID" --key-type API_KEY
resource "aws_api_gateway_usage_plan" "bronze" {
  name = "bronze"
  api_stages { api_id = aws_api_gateway_rest_api.orders.id  stage = "prod" }
  throttle_settings { rate_limit = 100  burst_limit = 200 }
  quota_settings   { limit = 1000000  period = "MONTH" }
}
resource "aws_api_gateway_api_key" "acme" { name = "acme" }
resource "aws_api_gateway_usage_plan_key" "acme" {
  key_id        = aws_api_gateway_api_key.acme.id
  key_type      = "API_KEY"
  usage_plan_id = aws_api_gateway_usage_plan.bronze.id
}

Throttle vs quota

Throttle (rate/burst) Quota (limit/period)
Time scale Per second Per day/week/month
Error 429 ThrottlingException 429 LimitExceededException
Purpose Smooth spikes Cap total consumption / billing tiers
Resets Continuously (token refill) At period boundary

Caching (REST)

A stage cache stores integration responses keyed by the request, cutting backend load and latency. It is REST-only and billed per hour by size.

Cache setting Values Note
Cache capacity 0.5, 1.6, 6.1, 13.5, 28.4, 58.2, 118, 237 GB Per stage; hourly charge
Default TTL 0–3600 s (default 300) 0 disables caching
Cache key parameters chosen path/query/header params Wrong keys → cross-user cache bleed
Encrypt cache data true/false Encrypt at rest
Per-method TTL/override override at method level Cache reads, not writes
Cache invalidation Cache-Control: max-age=0 + IAM perm InvalidateCache permission gates it
Cache size Approx $/hour Approx $/month
0.5 GB $0.020 ~$14.40
1.6 GB $0.038 ~$27.40
6.1 GB $0.200 ~$144
13.5 GB $0.250 ~$180
28.4 GB $0.500 ~$360

Cache-key mistakes are a data-leak class of bug: if you cache a per-user response but do not include the user identity in the cache key, user B can be served user A’s cached response. Only cache safely-shared responses, or include the authorizing header/claim in the cache key.

CORS, WAF, logging, X-Ray and private APIs

CORS — REST vs HTTP

Aspect REST API HTTP API
Preflight OPTIONS You add an OPTIONS method (MOCK) returning Access-Control-* Native: API GW answers OPTIONS automatically
Actual response headers Proxy: Lambda must return them; non-proxy: method response Native corsConfiguration adds them
Error responses Add CORS headers to gateway responses (DEFAULT_4XX/5XX) Handled by native config
Config Per method + gateway responses One corsConfiguration block

The nastiest CORS bug: preflight passes but the error response (a 401/429/500) lacks Access-Control-Allow-Origin, so the browser shows “CORS error” for what is actually an auth or throttle failure. On REST, add the header to the relevant gateway responses, not just the method.

# HTTP API native CORS
aws apigatewayv2 update-api --api-id "$HTTP_ID" \
  --cors-configuration AllowOrigins="https://app.example.com",AllowMethods="GET,POST,OPTIONS",AllowHeaders="authorization,content-type",MaxAge=300

WAF (REST only)

A WAFv2 WebACL attaches to a REST stage (regional scope). HTTP APIs have no native WAF — front them with CloudFront + WAF. Common managed rules: AWSManagedRulesCommonRuleSet, AWSManagedRulesAmazonIpReputationList, plus a rate-based rule.

aws wafv2 associate-web-acl --web-acl-arn "$WEBACL_ARN" \
  --resource-arn "arn:aws:apigateway:$AWS_REGION::/restapis/$REST_ID/stages/prod"

Logging: access vs execution

Log type Flavors Where Content
Access logs REST, HTTP CloudWatch Logs group you set One $context line per request
Execution logs REST Auto group per stage Phase-by-phase internal trace
Detailed metrics REST, HTTP CloudWatch metrics 4XX/5XX/Latency per method/route
Useful $context access-log field Tells you
$context.status Final HTTP status returned
$context.integrationErrorMessage Why the integration failed (502/504)
$context.authorizer.error Why an authorizer denied
$context.integration.latency Backend time (vs total responseLatency)
$context.error.responseType The gateway response type (e.g. THROTTLED)
$context.apiKeyId / $context.identity.apiKey Which client key called
{ "requestId":"$context.requestId", "ip":"$context.identity.sourceIp",
  "status":"$context.status", "err":"$context.error.message",
  "intErr":"$context.integrationErrorMessage", "authErr":"$context.authorizer.error",
  "latency":"$context.responseLatency", "intLatency":"$context.integration.latency" }

The one-time gotcha: REST execution and access logging to CloudWatch requires an account-level IAM role ARN set in API Gateway → Settings (cloudWatchRoleArn). If it is unset, logging silently does nothing. Set it once per region.

X-Ray and private APIs

Enable X-Ray active tracing on the stage to trace API-Gateway-to-integration latency. A private API (PRIVATE endpoint type, REST only) is reachable only through an interface VPC endpoint (com.amazonaws.<region>.execute-api) and needs a resource policy allowing that endpoint:

Private API piece What it does
Endpoint type PRIVATE No public execute-api URL
Interface VPC endpoint execute-api ENI with private DNS
Resource policy Allow aws:SourceVpce = your endpoint
DNS Private DNS resolves execute-api inside the VPC

Architecture at a glance

The diagram traces one request left to right through every gate. A client presents a JWT and/or an x-api-key; the request lands on a custom domain (ACM cert, base-path mapping), passes WAF (REST only), hits the prod stage, is checked by the authorizer (JWT/Lambda/Cognito/IAM), metered by the throttle + usage plan bucket, validated and optionally VTL-mapped, then handed to the Lambda proxy or an HTTP backend (via VPC link), which reads/writes DynamoDB — with access + execution logs, X-Ray traces and a CloudWatch alarm watching 5XXError and p99 Latency. Each numbered badge marks where a specific status code is emitted, so a failure names its own gate.

Left-to-right Amazon API Gateway request path: client with JWT and API key through a custom domain and WAF into the prod stage, then authorizer, throttle and usage plan, request validation and VTL mapping, into a Lambda proxy or HTTP backend reaching DynamoDB, with access and execution logs, X-Ray tracing and a CloudWatch alarm; six badges mark where 403, 401, 429, 400, 502 and 504 are emitted

Real-world scenario

Meridian Tickets, a mid-size events platform, ran a public REST API on API Gateway fronting 40 Lambdas. Their problems were textbook. First, cost: at 220 million requests/month they paid roughly $770/month for the API tier alone on REST, even though 34 of the 40 endpoints were plain Lambda proxies with no VTL, no caching, and no API keys. Second, a launch-day meltdown: when a headline act went on sale, traffic spiked to ~14,000 rps and callers got a wall of 429s — the account default of 10,000 rps they had never raised. Third, a mystery 403 on their partner integration that took a day to diagnose.

The migration was surgical. They split the API into two: the six partner-facing, metered, cached endpoints stayed on REST (usage plans gave partners a 1M-requests/month quota and a per-partner x-api-key, and a 300-second cache on the read-heavy /events endpoint cut Lambda invocations by 63%). The 34 simple proxy endpoints moved to an HTTP API with a Cognito JWT authorizer — dropping those from $3.50 to $1.00 per million and shaving ~40 ms of p50 latency off each call. Blended, the API bill fell to about $300/month, a 61% cut, and p99 latency on the migrated routes improved because the VTL pipeline was gone.

They fixed the spike by requesting an account quota increase to 20,000 rps and adding a usage plan throttle so a single partner could not consume the whole bucket; a stage-level rate of 12,000 rps with 6,000 burst absorbed the on-sale surge, and the cache soaked the read amplification. The partner 403 turned out to be the classic pair: the partner had been calling /v1 but the base-path mapping was only configured for the root, and separately their SDK dropped the x-api-key header on redirect so the apiKeyRequired method rejected them. Both were visible in one access-log line ($context.error.responseType = MISSING_AUTHENTICATION_TOKEN for the path, API_KEY_MISSING for the header) — logs they had never enabled because the account CloudWatch role ARN was unset. Enabling logging first would have turned a one-day hunt into a five-minute read. The lesson they wrote on the wall: pick the flavor per endpoint, raise the quota before the launch, and turn logs on before you need them.

Advantages and disadvantages

Advantages Disadvantages
Fully managed front door — no servers to patch/scale Per-request cost adds up at very high volume vs an ALB
Auth, throttle, validation before your code runs REST VTL pipeline adds latency and complexity
Scales 0 → tens of thousands rps automatically Account 10k rps default surprises launches
Per-client metering & quotas (REST usage plans) Usage plans/caching/WAF are REST-only (not HTTP)
Deep observability (access/exec logs, X-Ray) Logging off by default; account role ARN gotcha
Custom domains, mTLS, private endpoints 29-second integration ceiling on sync work
HTTP APIs are cheap and low-latency HTTP APIs drop features you may later need
Native JWT auth (HTTP) / Cognito (REST) Proxy response shape is unforgiving (502)

REST wins when you need metering, caching, VTL, WAF, private or edge-optimized endpoints. HTTP wins for cheap, fast, JWT-fronted proxying — the majority of internal microservices. WebSocket wins only when you genuinely need server push. When request rates get truly massive and features are minimal, compare against an ALB → Lambda front door, which is flat-rate per LCU rather than per request.

Hands-on lab

You will build two APIs on a free-tier-friendly account: (A) an HTTP API fronting a Lambda with a JWT authorizer backed by Cognito, and (B) a REST API with an API-key usage plan, method throttling, and a request-validation + mapping touch. Then you will test authorized, unauthorized and throttled calls, and tear everything down.

⚠️ Cost note: Lambda, API Gateway (first 1M REST / 1M HTTP calls) and Cognito (first 10k/50k MAUs) are free-tier eligible. A REST cache and a custom domain are not free — this lab skips the cache and domain; enable them only if you accept the hourly/cert cost. Tear down at the end regardless.

Step 0 — variables and a Lambda both APIs share

export AWS_REGION=ap-south-1
export ACCT=$(aws sts get-caller-identity --query Account --output text)

# Minimal execution role
cat > trust.json <<'EOF'
{ "Version":"2012-10-17","Statement":[{"Effect":"Allow",
  "Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}
EOF
ROLE_ARN=$(aws iam create-role --role-name apigw-lab-fn \
  --assume-role-policy-document file://trust.json --query Role.Arn --output text)
aws iam attach-role-policy --role-name apigw-lab-fn \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
sleep 10   # let the role propagate

# The handler (returns a correct proxy response shape)
mkdir -p fn && cat > fn/index.mjs <<'EOF'
export const handler = async (event) => {
  const claims = event.requestContext?.authorizer?.jwt?.claims
              || event.requestContext?.authorizer?.claims || {};
  return {
    statusCode: 200,
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ ok: true, user: claims.sub || "anon", path: event.rawPath || event.path })
  };
};
EOF
( cd fn && zip -q ../fn.zip index.mjs )
FN_ARN=$(aws lambda create-function --function-name apigw-lab \
  --runtime nodejs20.x --handler index.handler --role "$ROLE_ARN" \
  --zip-file fileb://fn.zip --query FunctionArn --output text)

Expected: FN_ARN prints an ARN like arn:aws:lambda:ap-south-1:123456789012:function:apigw-lab.

Step 1 — Cognito user pool (for the JWT authorizer)

POOL_ID=$(aws cognito-idp create-user-pool --pool-name apigw-lab-pool \
  --query UserPool.Id --output text)
CLIENT_ID=$(aws cognito-idp create-user-pool-client --user-pool-id "$POOL_ID" \
  --client-name lab-client --explicit-auth-flows ALLOW_USER_PASSWORD_AUTH ALLOW_REFRESH_TOKEN_AUTH \
  --query UserPoolClient.ClientId --output text)
# a test user with a permanent password
aws cognito-idp admin-create-user --user-pool-id "$POOL_ID" --username lab@example.com \
  --message-action SUPPRESS
aws cognito-idp admin-set-user-password --user-pool-id "$POOL_ID" \
  --username lab@example.com --password 'Lab-Passw0rd!' --permanent

Step 2 — HTTP API + JWT authorizer + route

HTTP_ID=$(aws apigatewayv2 create-api --name lab-http --protocol-type HTTP \
  --query ApiId --output text)
INT_ID=$(aws apigatewayv2 create-integration --api-id "$HTTP_ID" \
  --integration-type AWS_PROXY --integration-uri "$FN_ARN" \
  --payload-format-version 2.0 --query IntegrationId --output text)
AUTH_ID=$(aws apigatewayv2 create-authorizer --api-id "$HTTP_ID" --name jwt \
  --authorizer-type JWT --identity-source '$request.header.Authorization' \
  --jwt-configuration Audience="$CLIENT_ID",Issuer="https://cognito-idp.$AWS_REGION.amazonaws.com/$POOL_ID" \
  --query AuthorizerId --output text)
aws apigatewayv2 create-route --api-id "$HTTP_ID" --route-key 'GET /me' \
  --target "integrations/$INT_ID" \
  --authorization-type JWT --authorizer-id "$AUTH_ID"
aws apigatewayv2 create-stage --api-id "$HTTP_ID" --stage-name '$default' --auto-deploy
aws lambda add-permission --function-name apigw-lab --statement-id http-invoke \
  --action lambda:InvokeFunction --principal apigateway.amazonaws.com \
  --source-arn "arn:aws:execute-api:$AWS_REGION:$ACCT:$HTTP_ID/*/*/me"
HTTP_URL="https://$HTTP_ID.execute-api.$AWS_REGION.amazonaws.com"

Step 3 — test the JWT authorizer (401 then 200)

# no token → 401
curl -s -o /dev/null -w "%{http_code}\n" "$HTTP_URL/me"          # expect 401

# get an ID token, then call authorized
ID_TOKEN=$(aws cognito-idp initiate-auth --auth-flow USER_PASSWORD_AUTH \
  --client-id "$CLIENT_ID" \
  --auth-parameters USERNAME=lab@example.com,PASSWORD='Lab-Passw0rd!' \
  --query 'AuthenticationResult.IdToken' --output text)
curl -s -H "Authorization: $ID_TOKEN" "$HTTP_URL/me"             # expect {"ok":true,"user":"<sub>",...}

Expected: first call prints 401; second prints the JSON body with your Cognito sub. If the second returns 401, the token aud must equal the app client ID and the issuer must match the pool exactly.

Step 4 — REST API with API key, usage plan, throttle and validation

REST_ID=$(aws apigateway create-rest-api --name lab-rest \
  --endpoint-configuration types=REGIONAL --query id --output text)
ROOT=$(aws apigateway get-resources --rest-api-id "$REST_ID" --query 'items[0].id' --output text)
RES=$(aws apigateway create-resource --rest-api-id "$REST_ID" --parent-id "$ROOT" \
  --path-part items --query id --output text)
# GET /items requires an API key
aws apigateway put-method --rest-api-id "$REST_ID" --resource-id "$RES" \
  --http-method GET --authorization-type NONE --api-key-required
aws apigateway put-integration --rest-api-id "$REST_ID" --resource-id "$RES" \
  --http-method GET --type AWS_PROXY --integration-http-method POST \
  --uri "arn:aws:apigateway:$AWS_REGION:lambda:path/2015-03-31/functions/$FN_ARN/invocations"
aws apigateway create-deployment --rest-api-id "$REST_ID" --stage-name prod
aws lambda add-permission --function-name apigw-lab --statement-id rest-invoke \
  --action lambda:InvokeFunction --principal apigateway.amazonaws.com \
  --source-arn "arn:aws:execute-api:$AWS_REGION:$ACCT:$REST_ID/*/GET/items"

# API key + usage plan with a tight throttle so we can trip a 429
KEY_VALUE=$(aws apigateway create-api-key --name lab-key --enabled \
  --query value --output text)
KEY_ID=$(aws apigateway get-api-keys --name-query lab-key --include-values \
  --query 'items[0].id' --output text)
PLAN=$(aws apigateway create-usage-plan --name lab-plan \
  --throttle rateLimit=1,burstLimit=1 --quota limit=1000,period=DAY \
  --api-stages apiId="$REST_ID",stage=prod --query id --output text)
aws apigateway create-usage-plan-key --usage-plan-id "$PLAN" \
  --key-id "$KEY_ID" --key-type API_KEY
REST_URL="https://$REST_ID.execute-api.$AWS_REGION.amazonaws.com/prod/items"

Step 5 — test the API key (403) and the throttle (429)

# missing key → 403
curl -s -o /dev/null -w "%{http_code}\n" "$REST_URL"                    # expect 403

# with the key → 200
curl -s -H "x-api-key: $KEY_VALUE" "$REST_URL"                          # expect 200 JSON

# hammer it — rate 1/burst 1 means most of a burst is 429
for i in $(seq 1 10); do
  curl -s -o /dev/null -w "%{http_code} " -H "x-api-key: $KEY_VALUE" "$REST_URL"
done; echo                                                             # expect a mix of 200 and 429

Expected: no key → 403; with key → 200; the loop prints several 429s once the burst bucket empties. That is the usage-plan throttle doing its job.

Step 6 — turn on access logging (see the gates)

LG=$(aws logs create-log-group --log-group-name /apigw/lab-rest 2>/dev/null; \
     echo "arn:aws:logs:$AWS_REGION:$ACCT:log-group:/apigw/lab-rest")
aws apigateway update-stage --rest-api-id "$REST_ID" --stage-name prod \
  --patch-operations \
   op=replace,path=/accessLogSettings/destinationArn,value="$LG" \
   op=replace,path=/accessLogSettings/format,value='{"status":"$context.status","err":"$context.error.responseType","key":"$context.identity.apiKeyId"}'
# (Requires the one-time account CloudWatch role ARN — set it if logs stay empty:)
# aws apigateway update-account --patch-operations op=replace,path=/cloudwatchRoleArn,value=arn:aws:iam::$ACCT:role/<apigw-cloudwatch-role>

Step 7 — teardown (deletes everything)

aws apigatewayv2 delete-api --api-id "$HTTP_ID"
aws apigateway delete-usage-plan-key --usage-plan-id "$PLAN" --key-id "$KEY_ID"
aws apigateway delete-usage-plan --usage-plan-id "$PLAN"
aws apigateway delete-api-key --api-key "$KEY_ID"
aws apigateway delete-rest-api --rest-api-id "$REST_ID"
aws logs delete-log-group --log-group-name /apigw/lab-rest 2>/dev/null || true
aws lambda delete-function --function-name apigw-lab
aws cognito-idp delete-user-pool --user-pool-id "$POOL_ID"
aws iam detach-role-policy --role-name apigw-lab-fn \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role --role-name apigw-lab-fn

Verify teardown: aws apigateway get-rest-apis --query "items[?name=='lab-rest']" returns [], and aws apigatewayv2 get-apis --query "Items[?Name=='lab-http']" returns [].

Common mistakes & troubleshooting

This is the section you will reread at 2 a.m. Work top to bottom: read the status code, then the one access-log field that disambiguates it, then apply the fix.

The playbook

# Symptom Root cause Confirm (exact command / field) Fix
1 403 “Missing Authentication Token” on a URL that should exist Path/method not deployed, or wrong stage/base-path curl -i $URL; check $context.error.responseType=MISSING_AUTHENTICATION_TOKEN; get-resources Deploy the method; call the right stage; fix the base-path mapping
2 403 with a valid caller (REST) IAM AWS_IAM deny, resource policy deny, or WAF block Access log error.responseType = ACCESS_DENIED/WAF_FILTERED; WAF BlockedRequests Grant execute-api:Invoke; fix resource policy; tune WAF rule
3 403 “Forbidden” with x-amzn-ErrorType: ForbiddenException API key missing/disabled/wrong plan; apiKeyRequired true responseType=API_KEY_MISSING/INVALID_API_KEY; get-usage-plan-keys Send x-api-key; enable key; attach key to the plan on that stage
4 401 Unauthorized Missing/expired/wrong-audience JWT, or Lambda authorizer denied $context.authorizer.error; decode JWT exp/aud/iss Refresh token; set audience = app client ID; match issuer exactly
5 401 after it “worked a minute ago” Stale authorizer cache serving an old Deny/Allow Result TTL (default 300 s); authorizerResultTtlInSeconds Lower/clear TTL; flush cache; fix identity source key
6 429 Too Many Requests Account/stage/method/usage-plan throttle tripped x-amzn-ErrorType: ThrottlingException; 4XXError spike; compare Count to limit Raise the tightest bucket; request account quota increase
7 429 LimitExceededException at a period boundary Usage-plan quota exhausted (day/week/month) get-usage --usage-plan-id ...; responseType=QUOTA_EXCEEDED Raise quota, or wait for reset; move client to a bigger plan
8 502 Bad Gateway Lambda proxy returned wrong shape (object body, no statusCode) or threw $context.integrationErrorMessage; execution log “Malformed Lambda proxy response” Return {statusCode, body:"<string>"}; JSON.stringify the body
9 504 Gateway Timeout Integration exceeded the 29 s ceiling IntegrationLatency ~29,000 ms; integrationErrorMessage=Timeout Speed up backend; move slow work async (SQS/Step Functions); request timeout quota bump
10 500 Internal Server Error VTL template error, bad integration URI, or stage-variable Lambda permission Execution log “Execution failed due to configuration error”; Invalid permissions on Lambda function Fix the template/URI; add lambda:InvokeFunction for the alias
11 CORS error in the browser Preflight OK but error response lacks Access-Control-Allow-Origin Browser console “No ‘Access-Control-Allow-Origin’”; the failing call is 401/429/500 Add CORS headers to gateway responses (REST) / native CORS (HTTP)
12 Custom-domain 403 / TLS fail ACM cert in wrong region, or missing/empty base-path mapping get-base-path-mappings; cert region (us-east-1 for edge) Move cert to correct region; add the base-path mapping
13 Stage variable ${} not resolving Variable not set on the called stage, or typo get-stage --query variables; look for literal ${stageVariables.x} in URI Set the variable on that stage; fix the reference name
14 “My change isn’t live” (REST) Forgot to create a new deployment Compare get-stage deploymentId to latest get-deployments create-deployment --stage-name prod (or enable HTTP auto-deploy)
15 413 Request Entity Too Large Body over the 10 MB REST payload limit curl -i returns 413 Use multipart/S3 pre-signed upload; don’t POST large blobs through the API
16 No logs at all (REST) Account CloudWatch Logs role ARN unset API Gateway → Settings shows empty cloudWatchRoleArn update-account to set the role ARN once per region

Status / error-code reference

Code x-amzn-ErrorType / responseType Meaning Emitted by
400 BadRequestException / BAD_REQUEST_BODY Request validation failed Request validator / model
401 UnauthorizedException / UNAUTHORIZED Missing/invalid token JWT / Cognito / Lambda authorizer
403 AccessDeniedException / ACCESS_DENIED IAM/resource-policy/WAF deny IAM auth, resource policy, WAF
403 MissingAuthenticationTokenException Route/method not found for signature scheme Routing (wrong path/method)
403 ForbiddenException / API_KEY_* API key missing/invalid Usage plan / API key
404 NotFoundException Resource does not exist Routing
413 RequestTooLargeException Payload over 10 MB (REST) Method request
415 UnsupportedMediaTypeException Content-Type unmatched, passthrough=NEVER Integration request
429 ThrottlingException / THROTTLED Rate/burst throttle Account/stage/method/plan
429 LimitExceededException / QUOTA_EXCEEDED Usage-plan quota exhausted Usage plan
500 InternalServerErrorException VTL error / config error Integration request/response
502 BAD_GATEWAY Malformed integration/proxy response Integration
503 Backend unavailable Integration/backend
504 INTEGRATION_TIMEOUT Integration exceeded 29 s Integration

Decision table — from a symptom to a cause

If you see… It’s probably… Do this
403 with no execution log line WAF or resource-policy block (before logging) Check WAF sampled requests / resource policy
403 only on partner calls API-key/usage-plan or base-path issue Confirm key on plan + base-path mapping
401 that comes and goes Authorizer cache TTL Lower TTL, verify identity source
429 only during spikes Account/stage burst too low Raise burst; request quota increase
429 that resets at midnight Usage-plan quota (DAY) Raise quota / bigger plan
502 on a new deploy Proxy response shape regression Return string body + statusCode
504 on one slow endpoint Sync call past 29 s Make it async
CORS error on a failing call Missing CORS on gateway responses Add headers to gateway responses

The three nastiest, in prose

The malformed proxy response (502). New engineers write return { body: { ok: true } } and API Gateway returns 502 with “Malformed Lambda proxy response” in the execution log. The contract is strict: statusCode is required (REST 1.0), and body must be a string — so JSON.stringify it. HTTP API payload 2.0 forgives a bare object (wraps it 200 + stringified), which is why the same handler behaves differently across flavors and masks the bug until you move it to REST.

CORS masking auth/throttle failures. Your OPTIONS preflight is configured, the happy path works, but a 401 or 429 shows up in the browser as a generic “CORS error” because the error response has no Access-Control-Allow-Origin. On REST APIs, gateway responses (DEFAULT_4XX, DEFAULT_5XX, UNAUTHORIZED, THROTTLED) are separate from your method responses and need the CORS headers added explicitly. Until you do, every backend error looks like a frontend/CORS problem and you debug the wrong layer.

The 29-second wall (504). API Gateway integrations hard-cap at 29 seconds by default (raisable via a service-quota increase on REST/regional, but most accounts run the default). A report endpoint that scans a big table, a third-party call that occasionally hangs, or a cold-starting downstream can cross it and return 504 even though the Lambda eventually finished — you pay for the full Lambda run and the client got nothing. The fix is architectural: return a 202 Accepted with a job id, do the work asynchronously (SQS + worker Lambda, or Step Functions), and let the client poll or receive a webhook.

Best practices

Security notes

Cost & sizing

What drives the bill: request count × per-million rate (flavor-dependent), data transfer out, optional cache hours (REST), and any WAF and CloudWatch Logs ingestion. Caching and custom domains (via the ACM cert, which is free, but the edge-optimized path uses CloudFront) can add cost; the requests themselves dominate at scale.

Driver REST HTTP Notes
Requests $3.50/M (tiered to $1.51) $1.00/M (to $0.90) The dominant cost
Cache $0.02–$3.80/hour by size n/a Per stage, always-on charge
Data transfer out Standard AWS DTO rates Same Egress to the internet
WAF Per WebACL + per rule + per M requests via CloudFront REST-attached
Logs CloudWatch ingestion/storage Same Access + execution logs
Free tier (first 12 months) Amount
REST API calls 1,000,000 / month
HTTP API calls 1,000,000 / month
WebSocket messages 1,000,000 / month
Cache Not free-tier eligible
Monthly requests REST cost HTTP cost Rough INR (HTTP, ₹86/$)
1 million $3.50 $1.00 ~₹86
10 million $35 $10 ~₹860
100 million $350 $100 ~₹8,600

Right-sizing rules: move plain-proxy endpoints to HTTP for the 71% saving; add a REST cache only when the backend is the bottleneck and the response is cacheable (a 300 s TTL on a hot read can cut Lambda cost more than the cache costs); and compare an ALB → Lambda front door when volume is very high and you need almost none of API Gateway’s features — LCU-based pricing can beat per-request at scale.

Interview & exam questions

1. When would you choose a REST API over an HTTP API? When you need a REST-only feature: API keys and usage plans, response caching, VTL request/response transformation, request validation via models, WAF directly on the API, a private (VPC) endpoint, or an edge-optimized endpoint. Otherwise HTTP is cheaper and faster. (SAA-C03, DVA-C02)

2. Explain the difference between Lambda proxy and non-proxy integration. Proxy (AWS_PROXY) passes the entire request to Lambda as a fixed event and expects a fixed {statusCode, headers, body} response; no mapping templates. Non-proxy (AWS) uses VTL templates to map the request to the backend and the response back, giving fine control at the cost of complexity. (DVA-C02)

3. A Lambda-backed endpoint returns 502. What’s the most likely cause? A malformed proxy response — usually the function returned a non-string body or omitted statusCode. Fix by returning statusCode and a JSON.stringify-ed string body. (DVA-C02)

4. How do you meter and limit a specific partner’s usage? REST usage plans + API keys: create an API key, attach it to a usage plan with a throttle (rate/burst) and a quota (per day/week/month), associate the plan with the API+stage, and set apiKeyRequired on the methods. HTTP APIs cannot do this. (SAA-C03)

5. What are the throttle layers and their default? Account (region-wide, 10,000 rps steady / 5,000 burst by default), stage, method, and per-key usage plan. A request must pass every applicable bucket; the tightest returns 429. (SAA-C03, ANS-C01)

6. How does a JWT authorizer differ from a Cognito user pool authorizer? JWT authorizers are HTTP-API-only and work with any OIDC issuer (validating iss/aud/exp/scopes statelessly against a JWKS endpoint). Cognito user pool authorizers are REST-only and specific to Cognito pools. Both avoid running your code. (DVA-C02)

7. Why did my API return 504 even though the Lambda finished? The integration exceeded the 29-second timeout, so API Gateway returned 504 while the Lambda kept running (and billing). Move long work to an async pattern (SQS/Step Functions) and return 202. (DVA-C02)

8. How do you customize the error returned when the request body is invalid? Attach a request validator + model (JSON Schema) to the method; invalid bodies get a 400 (BAD_REQUEST_BODY) whose message you can customize via the BAD_REQUEST_BODY gateway response. (DVA-C02)

9. What’s the correct place for the ACM certificate on a custom domain? For edge-optimized custom domains, the cert must be in us-east-1; for regional custom domains (and all HTTP APIs), in the same region as the API. Wrong region → handshake/403 failures. (SAA-C03, ANS-C01)

10. How do you make an API reachable only from inside a VPC? Use a REST private API (PRIVATE endpoint type), create an interface VPC endpoint for execute-api, and add a resource policy allowing that aws:SourceVpce. No public URL exists. (ANS-C01, SCS-C02)

11. Where do throttling, logging and caching attach — the API or the stage? The stage. Deployments are immutable snapshots; the stage points at one and carries throttle, logging, cache, WAF, X-Ray and stage variables. (DVA-C02)

12. Your browser shows a CORS error on a call that returns 401. Why? The preflight is fine, but the 401 error response lacks Access-Control-Allow-Origin. On REST APIs, add CORS headers to the relevant gateway responses (UNAUTHORIZED/DEFAULT_4XX), not just the method. (DVA-C02)

Quick check

  1. Which two features are REST-only that most often force you off HTTP APIs?
  2. What is the default account request throttle (steady and burst)?
  3. What must a Lambda proxy response include to avoid a 502?
  4. Which authorizer type is HTTP-API-only, and what does it validate?
  5. Where must the ACM certificate live for an edge-optimized custom domain?

Answers

  1. Usage plans + API keys and response caching (also VTL, request validation, WAF, private/edge endpoints). 2. 10,000 rps steady-state, 5,000 burst, region-wide. 3. An integer statusCode and a string body (JSON must be stringified). 4. The JWT authorizer — validates a token’s signature, issuer, audience and expiry (and optional scopes) against a JWKS endpoint. 5. us-east-1, regardless of the API’s region.

Glossary

Term Definition
REST API Feature-rich API Gateway flavor: VTL, API keys/usage plans, caching, WAF, private/edge endpoints
HTTP API Cheaper, faster flavor for Lambda/HTTP proxying with native JWT auth; no VTL/keys/caching
WebSocket API Stateful two-way flavor with $connect/$disconnect/custom routes
Proxy integration Passes the whole request to the backend and expects a fixed response shape
Non-proxy integration Uses VTL mapping templates to translate request and response
Stage A named, live deployment of an API; anchor for throttle/log/cache/WAF
Deployment An immutable snapshot of an API definition (REST must redeploy to go live)
Stage variable ${stageVariables.x} per-stage config, e.g. to select a backend/alias
Authorizer Pre-integration identity check (JWT, Cognito, Lambda, IAM) emitting 401/403
JWT authorizer HTTP-API stateless token check against an OIDC issuer’s JWKS
Lambda authorizer Your function returns an IAM policy (token or request type), cached by identity source
Usage plan REST bundle of throttle (rate/burst) + quota tied to API keys
API key x-api-key client identifier for metering/throttling — not authentication
Mapping template VTL transform of request/response in a REST non-proxy integration
Model JSON Schema used by a request validator to reject bad bodies with 400
Gateway response Customizable canned 4XX/5XX response (where you add CORS headers to errors)
Canary release Splitting stage traffic between the current and a new deployment
Private API REST API reachable only via an interface VPC endpoint + resource policy

Next steps

AWSAPI GatewayServerlessLambdaJWTThrottlingUsage PlansREST API
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading