AWS Advanced #4: API Gateway + Lambda

10 min read

To call functions from #3 Lambda Basics over HTTP, you need a front door. The AWS standard is API Gateway — a managed gateway that sits in front of Lambda (or ECS, EC2, external backends).

This post covers the two flavors (REST / HTTP), Lambda integration patterns, authorization options, stages and deploys, and usage plans — all in one go.

Where API Gateway fits #

what API Gateway does
client (browser, app)
   │ HTTPS
API Gateway
   ├─ TLS termination
   ├─ Routing (path → backend)
   ├─ Auth / authz
   ├─ Rate limiting / throttling
   ├─ Request transformation
   └─ Caching
backend (Lambda / ECS / external HTTP)

The role overlaps with ALB (Intermediate #6) but with a different emphasis.

ALBAPI Gateway
Pricing modelHourly + LCUPer request + data
Cost at zero trafficHourly (~$20/month)0
Direct Lambda integrationPossibleNative
API key / Usage PlanNoneYes
CachingNoneYes (REST API)
Cognito integrationSeparateNatural
Where it shinesAlways-on big ECSBursty / serverless / API key needed

REST API vs HTTP API #

API Gateway has two flavors.

HTTP API (v2) — newer #

  • 70% cheaper, faster
  • Validates JWT tokens (Cognito / Auth0 / your own OIDC) directly
  • Simple CORS setup
  • Lambda proxy integration by default
  • No WebSocket, no API key / Usage Plan, no request transformation / caching

The default candidate for new projects. Simple REST API + JWT is enough.

REST API (v1) — older + richer #

  • Caching (per stage)
  • Request / response mapping transformations
  • Direct WAF integration
  • API Key + Usage Plan
  • WebSocket support

When you need complex transformation / usage segmentation / WebSocket.

How to choose #

decision tree
Need WebSocket? ─Yes→ REST API (or WebSocket API)
   │ No
Need API Key / Usage Plan? ─Yes→ REST API
   │ No
Need complex transformations / caching? ─Yes→ REST API
   │ No
HTTP API (cheap + fast)

This post goes with HTTP API, calling out REST-only features as we go.

First API — Hello, World #

Lambda function #

lambda_function.py
import json

def handler(event, context):
    name = event.get("queryStringParameters", {}).get("name", "world")
    return {
        "statusCode": 200,
        "headers": {"Content-Type": "application/json"},
        "body": json.dumps({"message": f"Hello, {name}!"})
    }

This is what we covered in #3. Deploy as hello-fn.

Create HTTP API (console) #

Console → API Gateway → “Create API” → “Build” under HTTP API.

  1. Add integration: Lambda → region / function (hello-fn)
  2. API name: hello-api
  3. Routes: GET /hellohello-fn
  4. Stage: $default (auto-deploy)
  5. Create

URL once done: https://abc123.execute-api.ap-northeast-2.amazonaws.com/hello?name=AWS

Via CLI #

HTTP API CLI
# create
API_ID=$(aws apigatewayv2 create-api \
  --name hello-api \
  --protocol-type HTTP \
  --target arn:aws:lambda:ap-northeast-2:123456789012:function:hello-fn \
  --query ApiId --output text)

# permission for API Gateway to invoke the Lambda
aws lambda add-permission \
  --function-name hello-fn \
  --statement-id apigw-invoke \
  --action lambda:InvokeFunction \
  --principal apigateway.amazonaws.com \
  --source-arn "arn:aws:execute-api:ap-northeast-2:123456789012:$API_ID/*/*"

echo "https://$API_ID.execute-api.ap-northeast-2.amazonaws.com/"

The --target shortcut creates route + integration in one. For finer control use create-route / create-integration separately.

Lambda integration — Proxy vs Non-proxy #

Two modes for API Gateway → Lambda.

Proxy integration (default for HTTP API) #

Pass the request mostly as-is to Lambda. Lambda returns a standard statusCode / headers / body shape.

proxy I/O
# Event
{
  "version": "2.0",
  "routeKey": "POST /users",
  "rawPath": "/users",
  "queryStringParameters": {"limit": "10"},
  "headers": {...},
  "body": "{\"name\":\"Alice\"}",
  "isBase64Encoded": false
}

# Lambda response
{
  "statusCode": 201,
  "headers": {"Content-Type": "application/json"},
  "body": "{\"id\":42}"
}

The production standard. The code owns its routing / response shape, so changes don’t touch API Gateway.

Non-proxy (REST API only) #

API Gateway transforms the request to Lambda’s preferred shape, transforms the response back. Defined with mapping templates (Velocity Template Language).

VTL mapping
{
  "name": "$input.path('$.name')",
  "user_id": "$context.authorizer.claims.sub"
}

Use when an existing Lambda wants clean inputs instead of raw events. Steep learning curve and harder to monitor — almost always use Proxy in new projects.

Routes and methods #

A route is path + method.

add multiple routes
# Catch-all
aws apigatewayv2 create-route \
  --api-id $API_ID --route-key 'ANY /'

# specific method
aws apigatewayv2 create-route \
  --api-id $API_ID --route-key 'GET /users'
aws apigatewayv2 create-route \
  --api-id $API_ID --route-key 'POST /users'
aws apigatewayv2 create-route \
  --api-id $API_ID --route-key 'GET /users/{userId}'

Parameters like {userId} come into the event’s pathParameters.

One Lambda for many routes #

For small APIs, one Lambda handles every route. Inside Lambda use a framework like FastAPI / Flask + an adapter (e.g. Mangum) for routing.

FastAPI on Lambda
from fastapi import FastAPI
from mangum import Mangum

app = FastAPI()

@app.get("/users")
def list_users():
    return [{"id": 1, "name": "Alice"}]

@app.get("/users/{user_id}")
def get_user(user_id: int):
    return {"id": user_id, "name": "Alice"}

handler = Mangum(app)  # Lambda handler

API Gateway routing is a single line: ANY /{proxy+}.

Lambda per route #

Bigger APIs split per route. Permissions / resources / deploy units become explicit.

route fan-out
GET  /users         → list-users-fn
GET  /users/{id}    → get-user-fn
POST /users         → create-user-fn

Splitting also distributes cold start — each function has its own warming and concurrency budget.

Authorization — who can call #

The most common case in API Gateway. Four options.

1) Open (no auth) #

Test / public APIs. Rare in production.

2) IAM auth #

Requires SigV4 signature on the request (AWS SDK does it automatically). Suits service-to-service inside an AWS account.

IAM-authed call (Python)
import boto3
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
import requests

session = boto3.Session()
creds = session.get_credentials()
url = "https://abc.execute-api.ap-northeast-2.amazonaws.com/hello"
req = AWSRequest(method="GET", url=url)
SigV4Auth(creds, "execute-api", "ap-northeast-2").add_auth(req)

resp = requests.get(url, headers=dict(req.headers))

Calling from a browser is impractical — signing in the client would expose credentials. This option is best suited for backend-to-backend calls.

3) JWT Authorizer (HTTP API) #

API Gateway directly validates ID tokens from Cognito User Pool / Auth0 / your own OIDC. HTTP API’s strength.

create JWT Authorizer
aws apigatewayv2 create-authorizer \
  --api-id $API_ID \
  --authorizer-type JWT \
  --identity-source '$request.header.Authorization' \
  --jwt-configuration '{
    "Audience": ["my-app"],
    "Issuer": "https://cognito-idp.ap-northeast-2.amazonaws.com/<UserPoolId>"
  }' \
  --name jwt-auth

Attach to a route and Authorization: Bearer eyJ... is auto-validated; claims land in the Lambda event at requestContext.authorizer.jwt.claims.

4) Lambda Authorizer (Custom) #

You write the validation Lambda yourself. Maximum flexibility.

lambda authorizer
def handler(event, context):
    token = event["headers"]["authorization"].replace("Bearer ", "")

    # custom verification (DB lookup / external call)
    user = verify_token_somehow(token)
    if not user:
        return {"isAuthorized": False}

    return {
        "isAuthorized": True,
        "context": {"user_id": user.id, "role": user.role}
    }

Cacheable (skip verification for the same token within TTL). Natural for integrations with external auth systems.

5) Cognito User Pool Authorizer (REST API only) #

Direct Cognito integration in REST API. Same role as HTTP API’s JWT Authorizer but in REST API.

CORS #

Mandatory for browser callers. With HTTP API it’s one console click.

HTTP API CORS
aws apigatewayv2 update-api \
  --api-id $API_ID \
  --cors-configuration "AllowOrigins=https://myapp.com,AllowMethods=GET,POST,AllowHeaders=*"

REST API requires manual OPTIONS responses per route — the console has an “Enable CORS” button that auto-generates them.

Stages and deployments #

HTTP API #

Auto-deploy is on by default. Route changes go live immediately. There’s a single $default stage.

For dev / staging / prod separation, make a separate API per environment.

REST API #

Explicit deploys + stages. You can have dev, staging, prod stages running different versions simultaneously.

aws apigateway create-deployment --rest-api-id $REST_ID --stage-name prod

Each stage can have its own caching, logging, throttling, and variables.

Throttling #

Defaults:

  • Account-wide: 10,000 req/s, burst 5,000
  • Per-route: configurable
cap a route at 100 req/s
aws apigatewayv2 update-stage \
  --api-id $API_ID --stage-name '$default' \
  --route-settings '{
    "GET /heavy": {
      "ThrottlingRateLimit": 100,
      "ThrottlingBurstLimit": 50
    }
  }'

Cap only the abusive / heavy routes — others stay unaffected.

API Key + Usage Plan (REST API) #

For exposing APIs to external users / partners.

structure
API Key (per-user unique string)
Usage Plan (limits, e.g. "100k req/month, 50 req/s")
Stage (the actual API)

Issue API Keys per user → connect to a Usage Plan → that user’s calls are auto-limited.

API Key + Plan
KEY_ID=$(aws apigateway create-api-key \
  --name customer-A --enabled --query id --output text)

PLAN_ID=$(aws apigateway create-usage-plan \
  --name basic-tier \
  --throttle 'rateLimit=50,burstLimit=100' \
  --quota 'limit=100000,period=MONTH' \
  --query id --output text)

aws apigateway create-usage-plan-key \
  --usage-plan-id $PLAN_ID --key-id $KEY_ID --key-type API_KEY

aws apigateway update-usage-plan \
  --usage-plan-id $PLAN_ID \
  --patch-operations 'op=add,path=/apiStages,value=<rest-api-id>:prod'

Callers send the header x-api-key: <key>.

HTTP API doesn’t support API Key / Usage Plan. Use REST API if you need them.

Caching (REST API) #

Per-stage response caching. Natural for GET requests.

aws apigateway update-stage \
  --rest-api-id $REST_ID --stage-name prod \
  --patch-operations 'op=replace,path=/cacheClusterEnabled,value=true' \
  --patch-operations 'op=replace,path=/cacheClusterSize,value=0.5'
  • Cache size: 0.5 GB – 237 GB
  • TTL: per-route
  • Invalidation: header Cache-Control: max-age=0 (only authorized callers)

Cost: hourly — only worth it for very high-traffic cases.

Logging — Access Log + Execution Log #

Access Log #

Per-request — for ops / business analytics.

enable access logs
aws apigatewayv2 update-stage \
  --api-id $API_ID --stage-name '$default' \
  --access-log-settings '{
    "DestinationArn": "arn:aws:logs:ap-northeast-2:123456789012:log-group:/aws/apigateway/hello-api",
    "Format": "{ \"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"requestTime\":\"$context.requestTime\",\"routeKey\":\"$context.routeKey\",\"status\":\"$context.status\" }"
  }'

JSON format — query in CloudWatch Logs Insights.

Execution Log (REST API) #

Per-stage internal logs — debugging only. Not enabled in production due to cost / noise.

Custom domain #

Expose under https://api.myapp.com/.... Wire up ACM (Intermediate #6) + Route 53 (Intermediate #5).

Custom Domain
aws apigatewayv2 create-domain-name \
  --domain-name api.myapp.com \
  --domain-name-configurations \
    "CertificateArn=arn:aws:acm:ap-northeast-2:...,EndpointType=REGIONAL"

# API → Domain mapping
aws apigatewayv2 create-api-mapping \
  --api-id $API_ID \
  --domain-name api.myapp.com \
  --stage '$default'

# Route 53 — A record alias
aws route53 change-resource-record-sets ...

Cost #

HTTP API #

  • $1.00 / 1M requests (~$0.000001)
  • Data Transfer separately

REST API #

  • $3.50 / 1M requests (3.5×)
  • Hourly add-on if caching is enabled

For 1M requests / month:

  • HTTP API: $1
  • REST API: $3.50 (+ caching / Usage Plan extras)

Not as cheap as Lambda alone, but still very affordable.

Common pitfalls #

1) Lambda doesn’t get called / permission denied #

lambda:InvokeFunction not granted to API Gateway. The console integration grants it automatically; with CLI you do aws lambda add-permission directly.

2) CORS isn’t lifting #

Browser console shows CORS error. Two places to check:

  • API Gateway CORS settings (above)
  • Access-Control-Allow-Origin header in Lambda response (the Lambda is responsible in proxy mode)

3) Timeout #

API Gateway integration timeout: max 30 seconds (REST), 30 seconds (HTTP). Lambda allows 15 minutes but API Gateway cuts you off. For work over 30 seconds, switch to async (Lambda Destination, Step Functions, SQS).

4) Body size limit #

API Gateway payload cap is 6 MB (each direction). Use the S3 presigned URL pattern for large files.

5) Unintended auto-deploy (HTTP API) #

Route / integration changes go live immediately in production. With CI managing IaC (Terraform / CDK) it’s intentional, but direct console edits are dangerous. Console for dev / staging APIs only.

6) Audience / issuer typo on JWT Authorizer #

A single character off in aud / iss and you get 401. Confirm the precise issuer URL for Cognito User Pool ID (https://cognito-idp.<region>.amazonaws.com/<UserPoolId>).

7) API Key isn’t end-of-line security #

API Keys are transmitted in plaintext by the client. Never use them as the sole auth mechanism for sensitive operations like payments. They are for usage segmentation and throttling. Real auth is JWT or IAM.

Wrap-up #

Here is what this post covered:

  • Where API Gateway fits — managed gateway in front of Lambda / ECS / external backends. Zero traffic = zero cost
  • HTTP API vs REST API — HTTP is 70% cheaper + faster. REST when you need WebSocket / API Key / caching
  • Lambda integration — Proxy (standard) vs Non-proxy (transform). New projects use Proxy
  • Routes / methods — patterns like GET /users/{id}. One Lambda routes all (Mangum etc.) vs split per route
  • Auth options — Open / IAM / JWT / Lambda Authorizer / (REST’s) Cognito User Pool
  • JWT Authorizer (HTTP API) — direct validation of Cognito / Auth0 / OIDC ID tokens
  • CORS — one line in HTTP API
  • Stages — HTTP API auto-deploys; REST API explicit + per-environment
  • Throttling — per-route caps
  • API Key + Usage Plan (REST only) — per-external-user limits
  • Caching (REST only) — per-stage
  • Access Log — JSON to CloudWatch Logs
  • Custom Domain — ACM + Route 53
  • Cost — HTTP API at $0.000001 per request, very cheap
  • Pitfalls — Lambda permission, CORS, 30s timeout, 6MB payload, HTTP API auto-deploy, JWT issuer typo, role of API Key

Up next — messaging infrastructure #

The synchronous side of API Gateway / Lambda is done. Now it’s time for asynchronous / event-driven communication.

#5 EventBridge / SQS / SNS covers the three side by side, fan-out patterns, DLQ, and idempotency — AWS’s messaging infrastructure, all in one piece.

X