API Gateway: REST APIs, Auth, Throttling & CORS on AWS
Design and deploy production REST APIs with Amazon API Gateway ā proxy integration, authorizers, throttling, CORS, request validation, usage plans, and multi-stage deployments.
API Gateway Essentials
Amazon API Gateway is the front door to your serverless backend. It handles:
- HTTP routing ā map
GET /appointmentsto a Lambda function - Auth ā Cognito JWT validation, API keys, Lambda authorizers
- Throttling ā rate limits per stage and per API key
- Request/response transformation ā reshape payloads without code
- CORS ā cross-origin headers for browser clients
- Caching ā cache responses at the edge
There are three flavors: REST API (feature-rich, more config), HTTP API (simpler, 70% cheaper, faster), and WebSocket API. For most CRUD backends use HTTP API. For advanced features (usage plans, request validation, caching) use REST API.
Lambda Proxy Integration
The most common pattern ā API Gateway passes the full request to Lambda and returns whatever Lambda returns:
Browser ā API Gateway ā Lambda proxy ā responseThe event Lambda receives:
{
"version": "2.0",
"routeKey": "GET /clinics/{id}",
"rawPath": "/clinics/CLN-001",
"rawQueryString": "include_staff=true",
"headers": {
"authorization": "Bearer eyJ...",
"content-type": "application/json"
},
"pathParameters": { "id": "CLN-001" },
"queryStringParameters": { "include_staff": "true" },
"body": null,
"requestContext": {
"accountId": "123456789",
"requestId": "abc-123",
"authorizer": {
"jwt": { "claims": { "sub": "user-id", "email": "doc@clinic.com" } }
}
}
}Lambda must return this exact shape:
def handler(event, context):
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": '{"id": "CLN-001", "name": "Sunrise Eye Care"}'
}Terraform: HTTP API Setup
# api.tf
resource "aws_apigatewayv2_api" "portal" {
name = "portal-api-${var.env}"
protocol_type = "HTTP"
cors_configuration {
allow_origins = ["https://portal.myapp.com"]
allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allow_headers = ["Authorization", "Content-Type"]
max_age = 86400
}
}
resource "aws_apigatewayv2_stage" "default" {
api_id = aws_apigatewayv2_api.portal.id
name = "$default"
auto_deploy = true
default_route_settings {
throttling_rate_limit = 100 # requests per second
throttling_burst_limit = 200
}
}
# Route: GET /appointments
resource "aws_apigatewayv2_route" "get_appointments" {
api_id = aws_apigatewayv2_api.portal.id
route_key = "GET /appointments"
authorization_type = "JWT"
authorizer_id = aws_apigatewayv2_authorizer.cognito.id
target = "integrations/${aws_apigatewayv2_integration.appointments.id}"
}
resource "aws_apigatewayv2_integration" "appointments" {
api_id = aws_apigatewayv2_api.portal.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.appointments.invoke_arn
payload_format_version = "2.0"
}
# Grant API GW permission to invoke Lambda
resource "aws_lambda_permission" "appointments_apigw" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.appointments.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.portal.execution_arn}/*/*"
}Authentication
Cognito JWT Authorizer (recommended)
Validates the Authorization: Bearer <token> header against your Cognito User Pool. Zero Lambda code needed:
resource "aws_apigatewayv2_authorizer" "cognito" {
api_id = aws_apigatewayv2_api.portal.id
authorizer_type = "JWT"
name = "cognito-authorizer"
identity_sources = ["$request.header.Authorization"]
jwt_configuration {
audience = [aws_cognito_user_pool_client.portal.id]
issuer = "https://cognito-idp.${var.region}.amazonaws.com/${aws_cognito_user_pool.main.id}"
}
}API Gateway validates the JWT signature, expiry, and audience. Claims are injected into event.requestContext.authorizer.jwt.claims.
In Lambda ā read the authenticated user:
def get_user(event: dict) -> dict:
claims = event["requestContext"]["authorizer"]["jwt"]["claims"]
return {
"user_id": claims["sub"],
"email": claims["email"],
"groups": claims.get("cognito:groups", "").split(",")
}
def handler(event, context):
user = get_user(event)
# user["user_id"] is verified ā no need to trust client-provided IDsLambda Authorizer (custom logic)
For API keys, internal service tokens, or custom auth schemes:
# authorizer_lambda/handler.py
def handler(event, context):
token = event["identitySource"] # e.g. header value
try:
payload = verify_custom_token(token)
return {
"isAuthorized": True,
"context": {"clinic_id": payload["clinic_id"]}
}
except Exception:
return {"isAuthorized": False}CORS in Practice
CORS is required when your React frontend (e.g., https://portal.clinic.com) calls an API on a different domain (https://api.clinic.com).
For HTTP API, configure it in the API resource (shown above). API Gateway handles the OPTIONS preflight automatically.
Manual CORS headers (fallback, if needed in Lambda):
CORS_HEADERS = {
"Access-Control-Allow-Origin": "https://portal.clinic.com",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE",
}
def response(status: int, body: dict) -> dict:
return {
"statusCode": status,
"headers": {"Content-Type": "application/json", **CORS_HEADERS},
"body": json.dumps(body, default=str)
}Request Validation (REST API)
For REST APIs, API Gateway can validate request bodies and query params before they hit Lambda ā rejecting bad requests early:
resource "aws_api_gateway_request_validator" "body" {
rest_api_id = aws_api_gateway_rest_api.portal.id
name = "body-validator"
validate_request_body = true
}
resource "aws_api_gateway_method" "post_appointment" {
rest_api_id = aws_api_gateway_rest_api.portal.id
resource_id = aws_api_gateway_resource.appointments.id
http_method = "POST"
authorization = "COGNITO_USER_POOLS"
request_validator_id = aws_api_gateway_request_validator.body.id
request_models = {
"application/json" = aws_api_gateway_model.appointment.name
}
}The model is a JSON Schema:
{
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"required": ["clinic_id", "patient_id", "datetime"],
"properties": {
"clinic_id": { "type": "string" },
"patient_id": { "type": "string" },
"datetime": { "type": "string", "format": "date-time" }
}
}Invalid requests get a 400 Bad Request response automatically ā your Lambda never executes.
Throttling & Usage Plans
Protect your backend from traffic spikes and abusive clients.
Stage-level throttling (applies to all routes):
resource "aws_apigatewayv2_stage" "default" {
api_id = aws_apigatewayv2_api.portal.id
name = "$default"
default_route_settings {
throttling_rate_limit = 500 # steady-state RPS
throttling_burst_limit = 1000 # burst capacity
}
}Per-route throttling (different limits per endpoint):
resource "aws_apigatewayv2_stage" "default" {
route_settings {
route_key = "POST /calls/transcribe"
throttling_rate_limit = 10 # AI endpoint ā more expensive
throttling_burst_limit = 20
}
}Usage Plans + API Keys (for external partners / B2B):
resource "aws_api_gateway_usage_plan" "partner" {
name = "partner-plan"
throttle_settings {
rate_limit = 100
burst_limit = 200
}
quota_settings {
limit = 10000
period = "MONTH"
}
}
resource "aws_api_gateway_api_key" "partner_key" {
name = "partner-clinic-key"
}
resource "aws_api_gateway_usage_plan_key" "partner" {
key_id = aws_api_gateway_api_key.partner_key.id
key_type = "API_KEY"
usage_plan_id = aws_api_gateway_usage_plan.partner.id
}Multi-Stage Deployments
dev.api.clinic.com ā API GW stage: dev ā Lambda alias: dev
staging.api.clinic.com ā stage: staging ā Lambda alias: staging
api.clinic.com ā stage: prod ā Lambda alias: prod# Lambda aliases point to specific versions
resource "aws_lambda_alias" "live" {
name = var.env # "dev", "staging", "prod"
function_name = aws_lambda_function.portal_api.function_name
function_version = aws_lambda_function.portal_api.version
}
# Custom domain per stage
resource "aws_apigatewayv2_domain_name" "portal" {
domain_name = "${var.env == "prod" ? "api" : var.env}.clinic.com"
domain_name_configuration {
certificate_arn = aws_acm_certificate.portal.arn
endpoint_type = "REGIONAL"
security_policy = "TLS_1_2"
}
}Debugging Tips
| Problem | How to diagnose | |---------|----------------| | 403 on all requests | Check authorizer config ā Cognito audience/issuer mismatch | | 502 Bad Gateway | Lambda is crashing ā check CloudWatch logs | | CORS errors in browser | OPTIONS preflight failing ā check allow_origins | | Timeout errors | Lambda timeout must be less than API GW timeout (API GW max = 29s) | | 429 Too Many Requests | Hit throttle limit ā increase or add exponential backoff |
Enable access logs:
resource "aws_apigatewayv2_stage" "default" {
access_log_settings {
destination_arn = aws_cloudwatch_log_group.apigw.arn
}
}Log format JSON for CloudWatch Insights querying:
{"requestId":"$context.requestId","ip":"$context.identity.sourceIp","routeKey":"$context.routeKey","status":"$context.status","responseLength":"$context.responseLength","integrationError":"$context.integrationErrorMessage"}REST API Knowledge Check
5 questions Ā· Test what you just learned Ā· Instant explanations
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.