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.
- Run here (level 1): SAM CLI 1.168.0 with AWS SAM Translator 1.114.0:
sam validate,sam build,sam local start-apiandsam local invokeagainst 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--helpthat the commands and the flags shown exist; I did not run them.
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 API | HTTP API |
|---|---|---|
| Endpoint types | Edge-optimized, Regional, Private | Regional only |
| IAM, Lambda authorizers, Cognito | Yes (Cognito directly) | Yes (Cognito through a JWT authorizer) |
| JWT authorizer | No (use a Lambda authorizer to check JWTs) | Yes |
| Resource policies; AWS WAF | Yes; Yes | No; No |
| API keys, per-client throttling, usage plans | Yes | No |
| Request validation | Yes | No |
| Request body transformation | Yes (mapping templates) | No (parameter mapping only) |
| Caching; canary releases; custom gateway responses; test invocations | Yes | No |
| Automatic deployments | No (you deploy) | Yes |
| Execution logs; X-Ray tracing; mock integrations | Yes | No (access logs only) |
| Private integration with AWS Cloud Map | No | Yes |
| 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 size | 10 MB | 10 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:
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.
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:
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:
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):
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:
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:
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:
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,qty0, an extra property, text that is not JSON, no body) all got200from 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:
/keyedis markedApiKeyRequiredand 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.examplegot the sameAccess-Control-Allow-Origin: https://example.comheader 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:
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:
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:
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:
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:
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:
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)
| Quota | REST API | HTTP API |
|---|---|---|
| Routes / resources per API | 300 resources (adjustable) | 300 routes (adjustable); 300 integrations (not adjustable) |
| Stages per API | 10 (adjustable) | 10 (adjustable) |
| Authorizers per API | 10 (adjustable via Support) | 10 (adjustable via Support) |
| API keys; usage plans | 10,000 keys per account per Region; 300 usage plans (adjustable) | Not applicable |
| Header size | 20,480 bytes in total | 10,240 bytes for request line plus headers |
| Mapping template; model | 300 KB; 400 KB per API | Not applicable |
| Method ARN length | 1,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:
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:
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.
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
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.
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.
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 solutionChapter 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-apiruns 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 /hellocollided on one server - Event versions differ: REST and payload 1.0 give
httpMethodandpath; HTTP payload 2.0 givesrawPath,routeKey,cookiesandrequestContext.http. 2.0 can infer a response; REST proxy needsstatusCode(else 502) - HTTP route priority: full match, then greedy
{proxy+}, then$default; no match is{"message":"Not Found"}. With a$defaultauthorizer, add an unauthenticatedOPTIONS /{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