API Gateway

AWS Intermediate/Advanced

Chapter 8 ยท Serverless APIs: API Gateway and SAM

AWS Intermediate/Advanced 7 covered Kubernetes on EKS. This chapter goes the other way: an API with no servers or clusters of your own. Amazon API Gateway receives HTTP requests, decides who may make them, and hands them to a backend, usually a Lambda function (AWS Fundamentals 7). The AWS SAM (Serverless Application Model) CLI lets you describe the API in a short template, run it on your own machine with Docker, and later deploy it. Infrastructure as code itself is in AWS Intermediate/Advanced 4 (CloudFormation) and 5 (the CDK); SAM is a third way to write the same thing, and this chapter shows what it expands into.

How every example in this chapter was verified
There is no AWS account and no AWS credentials on the machine that wrote this course, so nothing in this chapter was deployed and no real API Gateway was called. Docker Desktop's engine was running (it was empty at the start: 0 containers, 0 images), so, unlike chapter 7, the local experiments did run.
  • Run here (level 1): SAM CLI 1.168.0 with AWS SAM Translator 1.114.0: sam validate, sam build, sam local start-api and sam local invoke against real Lambda container images for Python 3.13 in Docker 29.8.2; cfn-lint in two versions (1.53.3 and 1.57.2); the SAM translator run offline to see what a template expands into; four small Python models (route selection, token-bucket throttling, authorizer caching, request cost).
  • From the documentation, not run (level 3): what the real service does. API Gateway pages were read on 2026-10-13 through a page-fetch tool that summarises pages, so each such statement says it comes from the docs. Pricing came from one page that has no price table, only worked examples (said again where it matters).
  • Needs an account, not run (level 2): sam deploy, sam sync, sam logs, sam delete. I checked with --help that the commands and the flags shown exist; I did not run them.
The local emulator is a development tool, and the chapter shows that it does not behave like the service in at least four ways. Treat "it worked locally" as a statement about your function code and routing, not about your API's security.

Which Kind of API?

API Gateway sells three products: REST APIs, HTTP APIs and WebSocket APIs. From the documentation: "REST APIs support more features than HTTP APIs, while HTTP APIs are designed with minimal features so that they can be offered at a lower price. Choose REST APIs if you need features such as API keys, per-client throttling, request validation, AWS WAF integration, or private API endpoints. Choose HTTP APIs if you don't need the features included with REST APIs." The rows below are the ones that matter for this chapter, read from the page's comparison tables:

Feature (docs, 2026-10-13)REST APIHTTP API
Endpoint typesEdge-optimized, Regional, PrivateRegional only
IAM, Lambda authorizers, CognitoYes (Cognito directly)Yes (Cognito through a JWT authorizer)
JWT authorizerNo (use a Lambda authorizer to check JWTs)Yes
Resource policies; AWS WAFYes; YesNo; No
API keys, per-client throttling, usage plansYesNo
Request validationYesNo
Request body transformationYes (mapping templates)No (parameter mapping only)
Caching; canary releases; custom gateway responses; test invocationsYesNo
Automatic deploymentsNo (you deploy)Yes
Execution logs; X-Ray tracing; mock integrationsYesNo (access logs only)
Private integration with AWS Cloud MapNoYes
Maximum integration timeout (quotas pages)50 ms to 29 s; can be raised above 29 s for Regional and private APIs, which "might require a reduction in your Region-level throttle quota"30 s, not adjustable
Payload size10 MB10 MB

WebSocket APIs keep a connection open for two-way messages. I did not read the WebSocket developer pages, so this chapter says nothing about how they work; the pricing page lists them at $1.00 per million messages and $0.25 per million connection minutes (US East (N. Virginia)), with messages metered in 32 KB steps and a 128 KB maximum.

Price. From the pricing page's worked examples (the page has no standalone price table, and the fetch tool inferred the tier boundaries): REST APIs $3.50 per million requests for the first 333 million, $2.80 for the next 667 million, $2.38 above a billion (Regions named in the examples: US East (N. Virginia and Ohio), US West (Oregon), Asia Pacific (Mumbai)); HTTP APIs $1.00 per million for the first 300 million and $0.90 above that (no Region stated). I modelled only the request charge; data transfer, caching, Lambda, logs, WAF and the free tier (1 million REST and 1 million HTTP calls a month for 12 months, for new customers only) are left out:

py cost_model.py
requests/month REST $ HTTP $ REST/HTTP 1,000,000 3.50 1.00 3.50 10,000,000 35.00 10.00 3.50 100,000,000 350.00 100.00 3.50 400,000,000 1,353.10 390.00 3.47 1,200,000,000 3,509.10 1,110.00 3.16

At these rates a REST request costs 3.5 times an HTTP request until the cheaper tiers start. That is a reason to ask which of the REST-only features you really need, not a reason to choose either by default: a judgement, not tested here: for a small public API that only needs routing, a JWT check and CORS, an HTTP API does the job for less; the moment you need API keys with per-client quotas, request validation or WAF, the REST API's extra cost is paying for features HTTP APIs do not have.

Defining the APIs with SAM

A SAM template is a CloudFormation template with one extra line, Transform: AWS::Serverless-2016-10-31, which lets it use short resource types such as AWS::Serverless::Function, AWS::Serverless::Api (a REST API) and AWS::Serverless::HttpApi (an HTTP API). A Globals section sets defaults for every function. Run here: a REST API with five kinds of route (open, a path parameter, a Lambda authorizer, an API key, and a validated JSON body) plus two deliberately broken routes used later, and a separate HTTP API. They are two templates on purpose; the reason appears in the local-run section.

AWSTemplateFormatVersion: "2010-09-09" Transform: AWS::Serverless-2016-10-31 Description: Chapter 8 REST API experiment Globals: Function: Runtime: python3.13 Timeout: 5 MemorySize: 128 Resources: HelloFn: Type: AWS::Serverless::Function Properties: CodeUri: ../app/hello/ Handler: app.handler Environment: Variables: STAGE_NAME: dev Events: Hello: Type: Api Properties: {RestApiId: !Ref RestApi, Path: /hello, Method: get} Item: Type: Api Properties: {RestApiId: !Ref RestApi, Path: "/items/{id}", Method: get} Secure: Type: Api Properties: RestApiId: !Ref RestApi Path: /secure Method: get Auth: Authorizer: TokenAuth Keyed: Type: Api Properties: RestApiId: !Ref RestApi Path: /keyed Method: get Auth: ApiKeyRequired: true Create: Type: Api Properties: RestApiId: !Ref RestApi Path: /items Method: post RequestModel: Model: ItemModel Required: true ValidateBody: true ValidateParameters: true
RestApi: Type: AWS::Serverless::Api Properties: StageName: dev Cors: AllowMethods: "'GET,POST,OPTIONS'" AllowHeaders: "'Content-Type,Authorization'" AllowOrigin: "'https://example.com'" MethodSettings: - ResourcePath: /* HttpMethod: "*" ThrottlingBurstLimit: 5 ThrottlingRateLimit: 2 Models: ItemModel: type: object required: [name] properties: name: {type: string, minLength: 1} qty: {type: integer, minimum: 1} additionalProperties: false Auth: ApiKeyRequired: false UsagePlan: CreateUsagePlan: PER_API Throttle: {BurstLimit: 5, RateLimit: 2} Quota: {Limit: 1000, Period: MONTH} Authorizers: TokenAuth: FunctionArn: !GetAtt AuthFn.Arn Identity: Header: Authorization ReauthorizeEvery: 0

The Lambda functions behind them are tiny: HelloFn returns the event's own shape so the experiments can see it, and the authorizer allows only the token allow-me:

def handler(event, context): token = event.get("authorizationToken", "") effect = "Allow" if token == "allow-me" else "Deny" return { "principalId": "user-1", "policyDocument": {"Version": "2012-10-17", "Statement": [{"Action": "execute-api:Invoke", "Effect": effect, "Resource": event["methodArn"]}]}, "context": {"plan": "gold"}, }

The HTTP API template is shorter because HTTP APIs have fewer settings. It has a JWT authorizer (Jwt), a greedy route (/files/{proxy+} for any method), CORS and a default throttle:

AWSTemplateFormatVersion: "2010-09-09" Transform: AWS::Serverless-2016-10-31 Description: Chapter 8 HTTP API experiment Globals: Function: Runtime: python3.13 Timeout: 5 MemorySize: 128 Resources: HttpFn: Type: AWS::Serverless::Function Properties: CodeUri: ../app/hello/ Handler: app.handler Events: Hello: Type: HttpApi Properties: {ApiId: !Ref HttpApi, Path: /hello, Method: get} Item: Type: HttpApi Properties: {ApiId: !Ref HttpApi, Path: "/items/{id}", Method: get} Secure: Type: HttpApi Properties: {ApiId: !Ref HttpApi, Path: /secure, Method: get, Auth: {Authorizer: Jwt}} Any: Type: HttpApi Properties: {ApiId: !Ref HttpApi, Path: "/files/{proxy+}", Method: any} HttpApi: Type: AWS::Serverless::HttpApi Properties: StageName: dev CorsConfiguration: AllowOrigins: ["https://example.com"] AllowMethods: [GET, POST] AllowHeaders: [Content-Type] DefaultRouteSettings: ThrottlingBurstLimit: 5 ThrottlingRateLimit: 2 Auth: Authorizers: Jwt: IdentitySource: $request.header.Authorization JwtConfiguration: issuer: https://issuer.example.com/ audience: [my-api]

What the templates expand into

sam validate checks a template, and with --lint runs cfn-lint on it. Run here: the HTTP template passed ("is a valid SAM Template", exit status 0). For the REST template, sam validate alone passed, but --lint exited with status 1 because cfn-lint 1.53.3 (the version the SAM CLI installed) raised W3005 warnings (an obsolete DependsOn) on resources SAM itself generates, which you cannot edit. With cfn-lint 1.57.2 (the version chapters 1 to 7 used) the same template was clean. That is the first sign that tool versions change what you see, and the section on mistakes the checkers miss comes back to it.

The SAM translator can also run offline, which shows what the short template is hiding. I ran it on both templates (the code locations were replaced with placeholder S3 addresses in memory, as sam package would produce):

py transform.py rest/template.yaml py transform.py http/template.yaml
rest/template.yaml: 22 resources, 9 types 1 AWS::ApiGateway::ApiKey 1 AWS::ApiGateway::Deployment 1 AWS::ApiGateway::RestApi 1 AWS::ApiGateway::Stage 1 AWS::ApiGateway::UsagePlan 1 AWS::ApiGateway::UsagePlanKey 4 AWS::IAM::Role 4 AWS::Lambda::Function 8 AWS::Lambda::Permission http/template.yaml: 8 resources, 5 types 1 AWS::ApiGatewayV2::Api 1 AWS::ApiGatewayV2::Stage 1 AWS::IAM::Role 1 AWS::Lambda::Function 4 AWS::Lambda::Permission

The REST template declares 8 resources and becomes 22; the HTTP template declares 2 and becomes 8. The extras are the Lambda permissions that let API Gateway call each function (8 of them in the REST case), the IAM role for each function, and, for REST, the deployment, the stage, a usage plan and an API key. Neither API contains separate resources for routes: SAM writes the whole API as an OpenAPI document inside the API resource. The REST validator and model, and the HTTP API's JWT scheme, are in that document:

REST: paths['/items'].post in the OpenAPI body SAM generates { "x-amazon-apigateway-integration": { "httpMethod": "POST", "type": "aws_proxy", "uri": { "Fn::Sub": "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HelloFn.Arn}/invocations" } }, "x-amazon-apigateway-request-validator": "body-and-params", "responses": {}, "parameters": [ { "required": true, "in": "body", "name": "itemmodel", "schema": { "$ref": "#/definitions/itemmodel" } } ] } REST: x-amazon-apigateway-request-validators { "body-and-params": { "validateRequestParameters": true, "validateRequestBody": true } } REST: definitions (the model) { "itemmodel": { "additionalProperties": false, "required": [ "name" ], "type": "object", "properties": { "name": { "minLength": 1, "type": "string" }, "qty": { "minimum": 1, "type": "integer" } } } } REST: deployment logical ID: RestApiDeployment2981488895 (the number changes when the API definition changes)
HTTP: components.securitySchemes and the /secure operation in the generated OpenAPI body { "securitySchemes": { "Jwt": { "type": "oauth2", "x-amazon-apigateway-authorizer": { "jwtConfiguration": { "issuer": "https://issuer.example.com/", "audience": [ "my-api" ] }, "identitySource": "$request.header.Authorization", "type": "jwt" } } }, "/secure get security": [ { "Jwt": [] } ], "payloadFormatVersion": "2.0" } HTTP: AWS::ApiGatewayV2::Stage properties { "DefaultRouteSettings": { "ThrottlingBurstLimit": 5, "ThrottlingRateLimit": 2 }, "ApiId": { "Ref": "HttpApi" }, "StageName": "dev", "Tags": { "httpapi:createdBy": "SAM" }, "AutoDeploy": true }

Two things in that output matter. First, the REST deployment's logical ID ends in a number that changed when I renamed a route and did not change when I changed a function's timeout:

py (transform before and after one change)
logical IDs of the REST deployment and stage in the template SAM generates as written : RestApiDeployment2981488895, RestApidevStage a route changed (/hello -> /hello2): RestApiDeployment11800fc711, RestApidevStage a function's Timeout 5 -> 7 : RestApiDeployment2981488895, RestApidevStage

That is how SAM gets around a REST API rule from the documentation: "Every time you update an API, you must redeploy the API to an existing stage or to a new stage. Updating an API includes modifying routes, methods, integrations, authorizers, resource policies, and anything else other than stage settings." A new deployment resource is a new logical ID, so CloudFormation creates it and repoints the stage. I did not read how SAM computes the number, only saw that it follows the API definition. Second, the generated HTTP stage has AutoDeploy: true, which matches the docs' table (HTTP APIs have automatic deployments, REST APIs do not).

Running the API Locally

Run here: sam build copied each function into .aws-sam/build, then sam local start-api started a web server on a local port and one Docker container per function. The first start built an emulation image from AWS's public Lambda Python 3.13 base image (two images of 795 MB and 834 MB stayed in Docker afterwards), and --warm-containers EAGER started the containers before the first request. Here is every request I sent to the REST API and what came back:

./probe_rest.sh # compact view of the saved run
GET /hello 200 OK event 1.0 (REST), CORS headers GET /items/42?x=1 200 OK event 1.0 (REST), CORS headers GET /secure (no token) 401 UNAUTHORIZED GET /secure (token allow-me) 200 OK event 1.0 (REST), CORS headers GET /secure (token nope) 403 FORBIDDEN GET /keyed (no API key) 200 OK event 1.0 (REST), CORS headers POST /items valid 200 OK event 1.0 (REST), CORS headers POST /items missing name 200 OK event 1.0 (REST), CORS headers POST /items qty 0 200 OK event 1.0 (REST), CORS headers POST /items extra property 200 OK event 1.0 (REST), CORS headers POST /items not JSON 200 OK event 1.0 (REST), CORS headers POST /items no body 200 OK event 1.0 (REST), CORS headers OPTIONS /hello origin example.com 200 OK CORS headers OPTIONS /hello origin evil.example 200 OK CORS headers GET /nope 403 FORBIDDEN DELETE /hello 403 FORBIDDEN GET /bad (no statusCode) 502 BAD GATEWAY GET /slow (8 s, timeout 5 s) 502 BAD GATEWAY

What the local server did like the service, and what it did not. Routing and path parameters worked; the Lambda authorizer worked (401 with no token, 403 for a token the authorizer denied, and the authorizer's context reached the function); CORS response headers were added; a function that returned something other than a proxy response (/bad) and one that ran over its timeout (/slow, 8 s against 5 s) both gave 502, and unknown routes gave the 403 "Missing Authentication Token" message. What it did not do:

  • Request validation was not enforced. Five bodies that break the model (missing name, qty 0, an extra property, text that is not JSON, no body) all got 200 from the function. The documentation says the real service "immediately fails the request, returns a 400 error response to the caller" before the integration runs; I could not test that.
  • API keys were not enforced: /keyed is marked ApiKeyRequired and answered 200 with no key.
  • Throttling was not applied. The stage allows a burst of 5 and 2 requests per second; I sent 30 requests back to back and all 30 returned 200.
  • CORS did not filter by origin: a preflight from https://evil.example got the same Access-Control-Allow-Origin: https://example.com header as the allowed origin, so the server only echoes your configuration; the browser, not the server, is what refuses a mismatched origin.

The HTTP API, run on its own, had the most worrying result:

./probe_http.sh # compact view of the saved run
GET /hello 200 OK event 2.0 GET /items/42?x=1 200 OK event 2.0 GET /secure (no token) 200 OK event 2.0 GET /secure (Bearer junk) 200 OK event 2.0 GET /files/a/b/c.txt (proxy+) 200 OK event 2.0 POST /files/x 200 OK event 2.0 OPTIONS /hello origin example.com 200 OK CORS headers GET /nope 403 FORBIDDEN DELETE /hello 403 FORBIDDEN

The JWT-protected route /secure answered 200 with no token at all, and with the junk token Bearer junk. The only sign was one line in the server log: "Authorizer 'Jwt' for route '/secure' was not found, skipping". The route was served unprotected. In the runs that had a Lambda authorizer the server also printed a banner ("does not guarantee 100% fidelity between authorizers locally and authorizers deployed on AWS"), but in the HTTP-only run the one skipping line was all I found about the JWT route. If you test an HTTP API locally, read the log and do not use "it returned 200" as evidence that a JWT route is protected. The DELETE and unknown-route requests got the REST-style 403 locally, while the documentation says an unmatched request to an HTTP API returns {"message":"Not Found"}, so the local server's error shapes are not a guide to the real HTTP API either.

One template with both API types is a trap. When I first put the REST and HTTP APIs in one template, both defined GET /hello and the one local server served only one of them:

combined template, one sam local start-api
combined template (a REST API and an HTTP API that both define GET /hello), one 'sam local start-api' server --- GET /hello (REST API defines it with HelloFn, HTTP API defines it with HttpFn) {"version": "2.0", "path": "/hello", "method": "GET", "pathParameters": {}, "query": null, "authorizer": null, "hasBody" --- OPTIONS /hello CORS headers (REST says Content-Type,Authorization; HTTP says Content-Type) Access-Control-Allow-Origin: https://example.com Access-Control-Allow-Methods: GET,OPTIONS,POST Access-Control-Allow-Headers: Content-Type

The request went to the HTTP API's function (event version 2.0), and the CORS headers came from the HTTP API's configuration, not the REST API's. On the real service the two APIs have separate URLs and never collide, so the local server's single namespace is a limit of the tool. Keep one API per template while testing locally.

Events: two shapes, and generated fixtures are samples

REST APIs (and HTTP APIs with payload format 1.0) send the Lambda function the 1.0 event; HTTP APIs default to 2.0 in the console and require you to say which when you use the CLI, CloudFormation or an SDK (docs). The documented differences: 2.0 has rawPath, a cookies list, requestContext.http, no multiValueHeaders, and duplicate headers or query values joined with commas. sam local generate-event makes sample events. Run here: I generated one of each type and listed the keys:

py mk8.py
REST fixture (sam local generate-event apigateway aws-proxy) top-level keys: body, resource, path, httpMethod, isBase64Encoded, queryStringParameters, multiValueQueryStringParameters, pathParameters, stageVariables, headers, multiValueHeaders, requestContext requestContext keys: accountId, resourceId, stage, requestId, requestTime, requestTimeEpoch, identity, path, resourcePath, httpMethod, apiId, protocol version field: (none: REST proxy events have no version field) | isBase64Encoded: True | pathParameters: {'proxy': '/hello'} HTTP API fixture (generate-event apigateway http-api-proxy) top-level keys: version, routeKey, rawPath, rawQueryString, cookies, headers, queryStringParameters, requestContext, body, pathParameters, isBase64Encoded, stageVariables requestContext keys: accountId, apiId, authentication, authorizer, domainName, domainPrefix, http, requestId, routeKey, stage, time, timeEpoch version field: 2.0 | isBase64Encoded: True | pathParameters: {'parameter1': 'value1'}

The fixtures are canned samples, not requests built from your route. I asked for --path hello, yet the REST fixture's pathParameters is {'proxy': '/hello'}, the HTTP fixture has {'parameter1': 'value1'}, and both have isBase64Encoded: true and a sample body. Edit them to look like the request you want to test before you trust a sam local invoke result.

Responses. With a REST (1.0) proxy integration the function must return statusCode, headers and body; /bad returned a dictionary without statusCode and the local server gave 502. With payload format 2.0 the docs say API Gateway "can infer the response format": valid JSON without a statusCode becomes a 200 response with content-type: application/json and the output as the body. Run here: locally an HTTP API function that returned {"message": "no statusCode here"} got 200 and Content-Type: application/json, and one that returned the string plain string got 200 with that text, as the docs describe. The same function on a REST API gave 502, so the difference is real, and a handler written for one API type is not automatically correct for the other.

Routes in HTTP APIs

A route is an HTTP method plus a path (GET /pets/{id}), ANY matches other methods, {proxy+} (greedy, last in the path) matches everything below it, and $default catches the rest. From the docs, the selection order is: "1. Full match for a route and method. 2. Match for a route and method with a greedy path variable ({proxy+}). 3. The $default route", and with no match the API returns {"message":"Not Found"}. I wrote a small model of that and tested it on the four rows of the docs' example table:

def select(routes, method, path): """routes: list of 'METHOD /path' keys or '$default'.""" best = None for key in routes: if key == "$default": continue m, p = key.split(" ", 1) if m not in (method, "ANY"): continue r = match_path(p, path) if not r: continue kind, statics, depth = r # order: full before greedy; exact method before ANY; more static segments; longer rank = (kind == "full", m == method, statics, depth) if best is None or rank > best[0]: best = (rank, key) if best: return best[1] return "$default" if "$default" in routes else None
py route_model.py
ok GET /pets/dog/1 -> GET /pets/dog/1 (docs: GET /pets/dog/1) ok GET /pets/dog/2 -> GET /pets/dog/{id} (docs: GET /pets/dog/{id}) ok GET /pets/cat/1 -> GET /pets/{proxy+} (docs: GET /pets/{proxy+}) ok POST /test/5 -> ANY /{proxy+} (docs: ANY /{proxy+}) extra cases (assumptions, not in the docs table): routes=['$default'] GET /anything -> $default routes=['GET /a'] GET /b -> None routes=['GET /a'] POST /a -> None routes=['ANY /a', 'GET /a'] GET /a -> GET /a routes=['GET /{proxy+}', 'GET /pets/{proxy+}'] GET /pets/x/y -> GET /pets/{proxy+} docs-table rows disagreeing with the model: 0

The model reproduced all four documented rows. The "extra cases" are my additions (a static segment beating a variable, the longer greedy prefix winning) and are not on the page. I then ran the same five routes through sam local and the emulator picked the same route for all the documented requests, with one difference worth noting: for POST /test/5 its event's routeKey was POST /{proxy+}, not the ANY /{proxy+} route you configured. I did not check what the real service puts in routeKey, so do not write a handler that depends on it.

Authorisation

From the documentation: both API types can use IAM (SigV4-signed requests), Lambda authorizers and Cognito (REST directly; HTTP through a JWT authorizer); REST adds resource policies; HTTP adds the JWT authorizer. I ran only one of them locally, the REST Lambda authorizer, and the results are in the probe table above.

Lambda authorizers

A Lambda authorizer receives the caller's identity and returns an IAM policy, a principal ID and optionally a context object that is passed to the integration. The docs describe two types: TOKEN (a bearer token in one header; the header name is the cache key) and REQUEST (headers, query strings, stage variables and context variables; "We recommend that you use a REQUEST authorizer", because it can use several identity sources). With caching on for a REQUEST authorizer, a missing identity source makes API Gateway return 401 without calling your function. In the docs' TOKEN example, a Deny policy gives 403 outside the test environment, the token unauthorized or an empty string gives 401, and any other token gives 500 (an error from the function). My local run agreed on 401 (no token) and 403 (denied).

The caching gotcha, from the docs: if you enable authorization caching, API Gateway "caches the policy so that the Lambda authorizer function isn't invoked again. Ensure that your policy is applicable to all resources and methods across your API." An authorizer that returns Allow for only the method ARN it was asked about will allow the first request and then refuse the same caller on other routes while the cache lasts. I modelled it (my reading of the docs, not run against the service) with the documented method-ARN shape and IAM-style wildcards:

py authz_cache.py
no cache, policy names only the requested ARN authorizer invoked 3x; results: GET /hello -> 200, GET /items/42 -> 200, POST /items -> 200 cache 300 s, policy names only the requested ARN authorizer invoked 1x; results: GET /hello -> 200, GET /items/42 -> 403, POST /items -> 403 cache 300 s, policy covers the whole stage (./dev/*) authorizer invoked 1x; results: GET /hello -> 200, GET /items/42 -> 200, POST /items -> 200

With no cache the narrow policy works, because the authorizer is called for every request, which is also why testing without caching hides the problem. With a 300-second cache the second and third requests were denied; a policy for .../dev/* fixed it. (The sample local run used ReauthorizeEvery: 0, caching off, so this effect never showed locally.)

JWT authorizers (HTTP APIs)

From the documentation: a JWT authorizer reads the token from the identity source (the token alone, or prefixed Bearer), checks the algorithm and signature against the issuer's jwks_uri (only RSA-based algorithms are supported; keys are cached for up to two hours), then validates kid, iss (must equal the configured issuer), aud or client_id (must match a configured audience; client_id is checked only if aud is absent), exp, nbf, iat and, if the route configures scopes, scope or scp (at least one must match). The claims reach a Lambda function in requestContext.authorizer.jwt.claims. Quotas on the HTTP API quotas page: 10 authorizers per API, 50 audiences per authorizer, 10 scopes per route, a 1,500 ms timeout for the key-set endpoint, and a 10,000 ms timeout for a Lambda authorizer's response.

The docs also warn that "there is no standard mechanism to differentiate JWT access tokens from other types of JWTs, such as OpenID Connect ID tokens", and recommend requiring authorization scopes on routes. In my reading, a route with a JWT authorizer and no scopes accepts any valid token from that issuer for that audience, ID tokens included; I did not test this against the service. The local emulator, as shown above, skipped the authorizer altogether.

Throttling, Usage Plans and Quotas

From the documentation: API Gateway "throttles requests to your API using the token bucket algorithm, where a token counts for a request". The rate is how fast tokens are added each second and the burst is the bucket's size. When the bucket is empty clients "may receive 429 Too Many Requests". The page calls limits "targets rather than guaranteed request ceilings". Four kinds of limit exist (AWS Regional limits, per-account per-Region limits, per-API per-stage limits for a method, and per-client limits tied to API keys in a usage plan), applied in this order: "1. per-client or per-method limits in a usage plan, 2. per-method limits for a stage, 3. account-level per Region, 4. AWS Regional". The account default is 10,000 requests per second with a maximum bucket of 5,000 across all API types in a Region (2,500 and 1,250 in a list of newer Regions), and the burst quota "is not a quota that a customer can control". The docs list no per-client throttling for HTTP APIs, so for them a stage or route setting is what the HTTP template uses (DefaultRouteSettings).

A model of the documented algorithm shows what rate and burst mean in practice. It is a model of the idea, not of the service's behaviour at the margins, which the page says is best effort:

py bucket_model.py
rate 2/s, burst 5, 30 requests all at once (spacing 0.0 s): 200s: 5 429s: 25 rate 2/s, burst 5, 30 requests at 10 per second (spacing 0.1 s): 200s: 10 429s: 20 first 429 at request # 7 rate 2/s, burst 5, 30 requests at 2 per second (spacing 0.5 s): 200s: 30 429s: 0 steady state: sustained ceiling is the rate; burst is how far a short spike may overrun it account default (docs): 10,000 rps, bucket 5,000; at spacing 0 the first 5,000 of 12,000 simultaneous requests pass: 200s: 5000 429s: 7000

A burst of 30 at once let 5 through; 30 requests at ten per second let 10 through and refused the 7th; 30 requests at two per second all passed. The local server applied none of this (30 of 30 returned 200), so throttling needs the real service (or a load test against it) to check.

Quotas worth knowing (docs, 2026-10-13)

QuotaREST APIHTTP API
Routes / resources per API300 resources (adjustable)300 routes (adjustable); 300 integrations (not adjustable)
Stages per API10 (adjustable)10 (adjustable)
Authorizers per API10 (adjustable via Support)10 (adjustable via Support)
API keys; usage plans10,000 keys per account per Region; 300 usage plans (adjustable)Not applicable
Header size20,480 bytes in total10,240 bytes for request line plus headers
Mapping template; model300 KB; 400 KB per APINot applicable
Method ARN length1,600 bytes (longer: 414 response)Not read

Request Validation, Mapping and CORS

REST request validation, from the docs. A request validator assigned to a method can check that required parameters in the URI, query string and headers are "included and not blank" ("only the existence of a parameter, not its type or format") and that the body "adheres to the configured JSON schema" for the request's content type; "if no matching content type is found, request validation is not performed" (use $default as the content type to apply one model to every type). A failure stops the request before the integration. SAM wrote exactly that into the generated document (body-and-params validator, the model under definitions, the validator on POST /items): see the OpenAPI output above. HTTP APIs have no request validation, so the function has to validate its own input, and so does anything the REST validator does not cover (types of query strings, business rules, a body whose content type differs from the one in the model).

CORS for HTTP APIs, from the docs. When CORS is configured, API Gateway "automatically sends a response to preflight OPTIONS requests, even if there isn't an OPTIONS route", adds the configured CORS headers to the integration's response, and ignores CORS headers your function returns. The headers are returned only for requests that carry an origin header (and a preflight needs Access-Control-Request-Method too). One trap: if a $default route has an authorizer, preflight requests (which browsers send without credentials) hit it too; the docs' fix is an OPTIONS /{proxy+} route with no authorizer and an integration attached. I did not read the REST CORS page, so what the Cors option in the REST template produces on the real service is not covered here; the local server returned the headers I configured.

Mapping. REST APIs can transform request bodies with mapping templates (the docs table: yes), HTTP APIs only map parameters. This chapter uses Lambda proxy integrations throughout, where API Gateway passes the request straight through, so no mapping templates are shown, and non-proxy Lambda, HTTP and AWS-service integrations (listed in the docs' tables as available for both types) were not examined.

Stages, Deployments and Custom Domains

From the documentation: a REST API is reachable only after you "create an API deployment and associate it with a stage". A stage (dev, prod) is "a logical reference to a lifecycle state" and part of the invoke URL: https://{restapi-id}.execute-api.{region}.amazonaws.com/{stageName}. A custom domain name replaces the hostname, and "to support multiple APIs under the custom domain name, you must map an API stage to a base path". HTTP APIs have a $default stage that serves requests no named stage matches; with none, the API answers {"message":"Not Found"} and writes no CloudWatch logs. I did not read the pages on custom domains, certificates or canary releases, so those are listed in the docs' tables but not explained here (custom domains and certificates come back in AWS Intermediate/Advanced 10).

What the Checkers Miss

Run here: I changed one thing at a time in the two templates (25 changes) and ran sam validate (the SAM translator) and cfn-lint 1.53.3 and 1.57.2 on each. The first two rows are the unchanged templates as controls:

py mutate_sam.py
change sam validate cfn-lint 1.53.3 cfn-lint 1.57.2 rest: the templates as written (control) pass warning only pass http: the templates as written (control) pass pass pass rest: Runtime python3.13 -> python3.99 pass error E3030 pass rest: Runtime python3.13 -> python3.7 (retired) pass error E2531 error E2531 rest: Timeout 5 -> 901 pass warning only pass rest: MemorySize 128 -> 64 pass error E3034 error E3034 rest: Handler line removed from HelloFn error error E0001 warning only rest: event Method get -> fetch pass warning only pass rest: event Path without leading slash pass warning only pass rest: StageName dev -> 'dev stage' pass warning only pass rest: CORS origin without inner quotes pass warning only pass rest: RequestModel names a model that does not exist error error E0001 pass rest: route uses an authorizer that is not defined error error E0001 pass rest: ReauthorizeEvery 0 -> 3601 pass warning only pass rest: authorizer FunctionArn -> plain text pass warning only pass rest: MethodSettings rate 2 -> 'fast' pass error E3012 error E3012 rest: usage plan Quota Period MONTH -> YEAR pass warning only pass rest: usage plan burst below rate (1 < 2) pass warning only pass rest: same path+method declared twice error error E0001 pass rest: model type 'object' -> 'objekt' pass warning only pass http: JwtConfiguration removed error error E0001 pass http: JWT identity source -> 'Authorization' pass pass pass http: JWT issuer not a URL pass pass pass http: throttle burst 5 -> 'lots' pass error E3012 pass http: {proxy+} not at the end of the path pass pass pass http: route Method any -> anyy pass pass pass http: CORS AllowMethods GET -> FETCH pass pass pass 25 one-change mutations. Errors reported by: sam validate 5; cfn-lint 1.53.3 10; cfn-lint 1.57.2 3. Caught by sam validate or cfn-lint 1.53.3: 10; by sam validate or cfn-lint 1.57.2: 8; by at least one of the three: 10; by none: 15.

Of 25 changes, sam validate reported an error for 5, cfn-lint 1.53.3 for 10 and cfn-lint 1.57.2 for 3; 10 were caught by at least one of the three and 15 by none. The 15 that nobody flagged include a nonexistent HTTP method (fetch, anyy), a route path without a leading slash, a Lambda timeout of 901 seconds, an authorizer caching time of 3,601 seconds, an authorizer FunctionArn that is plain text, a stage name with a space, a model whose type is objekt, a JWT issuer that is not a URL, an identity source that is not a request expression, a greedy {proxy+} in the middle of a path (the docs say it must be last), and a usage plan with a burst smaller than its rate. "Not caught" means these tools did not say so; I did not deploy them, so I do not know which the service would reject.

Two other lessons. First, the five changes that referred to something that does not exist or is missing (an undefined authorizer or model, a duplicated route, a JWT authorizer with no configuration, a function with no handler) were caught by the SAM translator, which is what sam validate runs; cfn-lint 1.53.3 repeated those failures as E0001 and 1.57.2 did not. Schema checks catch wrong types and out-of-range values (a retired Python runtime, a memory size of 64, a throttle that is a word) but not meaning. Second, the two cfn-lint versions disagree: 1.57.2 reported errors for 3 of the changes and missed 7 that 1.53.3 reported (the nonexistent runtime python3.99, a throttle of lots, and the five translator failures). In 1.53.3 the unchanged REST template also had warnings that 1.57.2 did not. I used the same translator version (1.114.0) in both environments, so the translator is not the reason, but I did not look for the cause. The practical rule: pin the lint version in your project, and re-run your own deliberate-mistake test whenever you upgrade it.

Deploying (not run)

Level 2, needs an AWS account, not run here. I confirmed with --help that these commands and flags exist in SAM CLI 1.168.0:

# first time: prompts for stack name, Region, and whether SAM may create IAM roles sam deploy --guided # later, without prompts (the S3 bucket for the code is chosen for you) sam deploy --stack-name reports-api --resolve-s3 --capabilities CAPABILITY_IAM # faster loop while developing: sync code changes straight to the stack sam sync --stack-name reports-api --watch # follow the Lambda logs sam logs --stack-name reports-api --tail # remove the stack and the artifacts sam deploy created sam delete --stack-name reports-api

The sam local start-api banner itself says that the way to test authorizers "deployed on AWS" is sam sync. Before any real deployment, set a budget alert (AWS Fundamentals 9) and check the free tier: the pricing page says its free offers are "only available to new AWS customers".

Cost and Clean-Up

Nothing in this chapter created anything in AWS. On this machine: the scratch folder C:/lwaws/c8 (about 540 MB, mostly two Python environments of 337 MB and 206 MB), and in Docker two Lambda base images (795 MB and 834 MB; Docker was empty before) and no containers: with --warm-containers EAGER, stopping the server left its containers running, so I removed them after each run. If you run this yourself, docker ps -a after a local session and remove the leftovers. For real deployments: the request charges modelled above, plus Lambda, logs and data transfer, which I did not price.

What was and was not checked
Run here: sam validate and --lint on two templates; the SAM translator offline (8 to 22 and 2 to 8 resources); sam build, sam local start-api and sam local invoke in Docker for a REST and an HTTP API (18 REST requests, 11 HTTP requests (two of them repeated), a combined-template run, a route-priority run and a 30-request burst); 25 template mutations against three tools; four models. From the documentation, not run: every statement about what the real API Gateway does (feature table, quotas, throttling, validation, CORS, JWT and Lambda authorizer behaviour, routing, deployment, prices). Not read: WebSocket APIs, REST CORS, custom domains and certificates, canary releases, mapping templates, caching, usage plan and API key mechanics, resource policies, private APIs, access logging, Lambda authorizers for HTTP APIs, IAM authorization details, the pricing of caching and data transfer. Not checked at all: a real API in a real account; the local server's behaviour for HTTP API Lambda authorizers.

Practice

Exercise 1

Write a SAM template for a REST API (open route, path parameter, Lambda authorizer, validated JSON body, throttle, usage plan) and a second one for an HTTP API (JWT authorizer, greedy route, CORS). Run sam validate --lint, translate both offline and count what they expand into, extract the generated OpenAPI, show how the REST deployment's logical ID changes, and make 25 one-change mutations against sam validate and two cfn-lint versions. Report what none of them catches.

๐Ÿ“„ View solution
Exercise 2

Build and run each API locally with sam local start-api (one API per template), probe it with curl (authorizer allowed, denied and missing, five invalid bodies, a missing API key, a 30-request burst, CORS preflights, a non-proxy response and a timeout), and list what the emulator enforces and what it does not. Show that a JWT route is served without a token and find the log line that says so. Then put both APIs in one template and show what collides.

๐Ÿ“„ View solution
Exercise 3

Model HTTP API route selection from the documented priority rules and test it on the docs' example table and against the emulator; model token-bucket throttling and show what a burst, a fast stream and a slow stream do; model a cached Lambda authorizer policy that names only one method ARN; and compute the request charge of REST and HTTP APIs at five traffic levels. For each of three scenarios you invent, choose REST or HTTP and say which docs row decided it.

๐Ÿ“„ View solution

Chapter 8 Quick Reference

  • REST API: API keys, usage plans, request validation, WAF, resource policies, caching, private endpoints, mapping templates; you deploy. HTTP API: cheaper, JWT authorizer, automatic deployments, no validation, no usage plans. Check the docs' feature table before choosing
  • SAM is CloudFormation plus Transform: AWS::Serverless-2016-10-31: 8 declared resources became 22 for the REST API, 2 became 8 for the HTTP API; routes live in a generated OpenAPI document
  • sam local start-api runs your Lambda code in Docker with real routing and a Lambda authorizer, but did not enforce request validation, API keys, throttling or a JWT authorizer (the JWT route was served open and only the log said so)
  • One API per template while testing locally: a REST and an HTTP API sharing GET /hello collided on one server
  • Event versions differ: REST and payload 1.0 give httpMethod and path; HTTP payload 2.0 gives rawPath, routeKey, cookies and requestContext.http. 2.0 can infer a response; REST proxy needs statusCode (else 502)
  • HTTP route priority: full match, then greedy {proxy+}, then $default; no match is {"message":"Not Found"}. With a $default authorizer, add an unauthenticated OPTIONS /{proxy+}
  • Lambda authorizer caching: the cached policy must cover every route the caller may use; a policy naming one method ARN breaks the others while cached
  • Throttling is a token bucket (rate = refill per second, burst = bucket size); 429 when empty; account default 10,000 rps and 5,000 burst per Region; usage-plan and per-method limits are applied first
  • Checkers miss a lot: of 25 one-change template mutations, 15 were caught by none of sam validate, cfn-lint 1.53.3 or 1.57.2, and the two cfn-lint versions disagreed. Pin versions and keep your own mistake tests
Coming next
Chapter 9 is messaging and events: SQS queues (standard and FIFO, visibility timeouts, dead-letter queues), SNS fan-out, EventBridge buses and rules, and Step Functions. An emulator for those services will only be used if it can be shown to behave correctly; otherwise the chapter will say which parts come from the documentation. Docker was working for this chapter and nothing needs starting first. Start with AWS Intermediate/Advanced 9.