Photo by Dave McDermott on Unsplash
To find API Gateway methods without authorization, list REST APIs with GetRestApis, call GetResources with embed=methods, and flag methods whose authorizationType is NONE. For HTTP and WebSocket APIs, call GetApis and GetRoutes and flag routes with AuthorizationType NONE. Then check API keys, resource policies and private endpoints before calling a method public.
An API Gateway method with no authorizer passes every request that reaches it straight to your Lambda function or backend. Sometimes that’s the point, as with a health check or a webhook that verifies a signature itself. Often it’s a DELETE someone added during a demo. This example is for engineers who want to find API Gateway methods without authorization across every Region and tell the intended ones from the accidents.
You’ll get a read-only TypeScript script for the AWS SDK for JavaScript v3. It does for API Gateway what the script to find public Lambda function URLs does for function URLs, the other common way to put a Lambda function on the internet without an authorizer.
What counts as “no authorization” in API Gateway?
REST API methods have an authorizationType of NONE, AWS_IAM, CUSTOM (a Lambda authorizer) or COGNITO_USER_POOLS. HTTP API routes use NONE, AWS_IAM, CUSTOM or JWT. NONE is the finding, but it’s not the whole story for REST APIs, which have other controls. The script weighs them like this:
Method or route with NONE |
Verdict | Why |
|---|---|---|
| Public REST method, no API key, no resource policy | HIGH | Anyone on the internet who finds the URL can call it. |
| HTTP API route | HIGH | HTTP APIs don’t support resource policies, so nothing else restricts callers. |
WebSocket $connect route |
HIGH | Authorization on WebSocket APIs happens only at connection time. |
| REST method with a resource policy | MEDIUM | The policy may allow only certain IPs, VPCs or accounts; read it. |
| REST method that only requires an API key | MEDIUM | An API key identifies a client for usage plans; it isn’t authorization. |
| Private REST API | INFO | Reachable only through interface VPC endpoints that its resource policy allows. |
| API with no stages | LOW | Not deployed, so not reachable yet. |
AWS is explicit about API keys: don’t use them for authentication or authorization, because a key valid for one API in a usage plan works for every API in that plan. Missing authentication on a sensitive operation is a textbook weakness, catalogued as CWE-306: Missing Authentication for Critical Function, and it maps to API2:2023 Broken Authentication in the OWASP API Security Top 10.
Why are OPTIONS methods skipped?
Browsers send CORS preflight requests as OPTIONS without credentials, so preflight methods are normally open on purpose. REST APIs usually implement them as unauthenticated OPTIONS methods. HTTP APIs with a CORS configuration answer preflight requests automatically, even without an OPTIONS route; if you protect a $default route with an authorizer, AWS suggests an unauthenticated OPTIONS /{proxy+} route so preflight still works. Pass --include-options to list them anyway.
What does the script do?
- Lists Regions
DescribeRegions, or the list you pass with--regions=. - Reads REST APIs
paginateGetRestApisgives the endpoint type, the resource policy anddisableExecuteApiEndpoint;GetStagesshows whether the API is deployed. - Reads every method
paginateGetResourceswithembed: ["methods"]returns each resource’s methods, includingauthorizationTypeandapiKeyRequired, 500 resources per page. - Reads HTTP and WebSocket routes
GetApis,GetRoutesandGetStagesfrom the API Gateway V2 API, followingNextToken. - Grades and exitsA table sorted by severity, and exit code 2 when anything is HIGH.
Prerequisites
- Node.js 18 or later, npm,
tsx, and the packages@aws-sdk/client-api-gateway,@aws-sdk/client-apigatewayv2and@aws-sdk/client-ec2. - A profile set up as described in AWS SDK v3 credential providers like fromIni and fromSSO.
Which IAM permissions does it need?
API Gateway uses HTTP verbs as IAM actions: every read is apigateway:GET on a path-style ARN. Nothing in this policy can change an API.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListRegions",
"Effect": "Allow",
"Action": "ec2:DescribeRegions",
"Resource": "*"
},
{
"Sid": "ReadApiGateway",
"Effect": "Allow",
"Action": "apigateway:GET",
"Resource": [
"arn:aws:apigateway:*::/restapis",
"arn:aws:apigateway:*::/restapis/*",
"arn:aws:apigateway:*::/apis",
"arn:aws:apigateway:*::/apis/*"
]
}
]
}
The script to find API Gateway methods without authorization
// find-api-gateway-methods-without-authorization.ts
// Finds API Gateway REST API methods and HTTP/WebSocket API routes that have no authorizer
// (authorizationType NONE) in every Region, and weighs each one against API keys, resource policies,
// private endpoints and whether the API is deployed. Report-only: it changes nothing.
// Usage: npx tsx find-api-gateway-methods-without-authorization.ts [--regions=us-east-1,eu-west-1]
// [--include-options] [--ignore=/health,/status]
import { EC2Client, DescribeRegionsCommand } from "@aws-sdk/client-ec2";
import {
APIGatewayClient,
paginateGetRestApis,
paginateGetResources,
GetStagesCommand,
type RestApi,
} from "@aws-sdk/client-api-gateway";
import {
ApiGatewayV2Client,
GetApisCommand,
GetRoutesCommand,
GetStagesCommand as GetV2StagesCommand,
type Api,
type Route,
} from "@aws-sdk/client-apigatewayv2";
const args = process.argv.slice(2);
const listArg = (name: string) =>
args.find((a) => a.startsWith(`--${name}=`))?.split("=")[1]?.split(",").map((s) => s.trim()).filter(Boolean);
const regionArg = listArg("regions");
const ignored = new Set(listArg("ignore") ?? []);
const includeOptions = args.includes("--include-options");
type Severity = "HIGH" | "MEDIUM" | "LOW" | "INFO";
interface Finding {
Region: string;
Api: string;
Type: string;
Method: string;
Path: string;
Severity: Severity;
Reason: string;
}
async function listRegions(): Promise<string[]> {
if (regionArg) return regionArg;
const out = await new EC2Client({}).send(new DescribeRegionsCommand({}));
return (out.Regions ?? []).map((r) => r.RegionName ?? "").filter(Boolean).sort();
}
// API Gateway returns the resource policy as a string with escaped quotes.
function hasResourcePolicy(api: RestApi): boolean {
const raw = api.policy?.trim();
if (!raw) return false;
try {
const doc = JSON.parse(raw.replace(/\\"/g, '"').replace(/\\\//g, "/")) as { Statement?: unknown[] | object };
return Array.isArray(doc.Statement) ? doc.Statement.length > 0 : Boolean(doc.Statement);
} catch {
return true; // unparseable but present: let a human read it
}
}
async function scanRest(region: string): Promise<Finding[]> {
const client = new APIGatewayClient({ region });
const findings: Finding[] = [];
for await (const page of paginateGetRestApis({ client }, { limit: 500 })) {
for (const api of page.items ?? []) {
if (!api.id) continue;
const name = `${api.name ?? "?"} (${api.id})`;
const isPrivate = (api.endpointConfiguration?.types ?? []).includes("PRIVATE");
const policy = hasResourcePolicy(api);
const stages = (await client.send(new GetStagesCommand({ restApiId: api.id }))).item ?? [];
for await (const res of paginateGetResources({ client }, { restApiId: api.id, embed: ["methods"], limit: 500 })) {
for (const resource of res.items ?? []) {
const path = resource.path ?? "?";
if (ignored.has(path)) continue;
for (const [verb, method] of Object.entries(resource.resourceMethods ?? {})) {
if (method.authorizationType !== "NONE") continue;
if (verb === "OPTIONS" && !includeOptions) continue; // CORS preflight, normally unauthenticated
let severity: Severity = "HIGH";
let reason = "no authorizer: anyone who can reach the endpoint can call it";
if (isPrivate) [severity, reason] = ["INFO", "private API: reachable only through VPC endpoints its resource policy allows"];
else if (policy) [severity, reason] = ["MEDIUM", "no authorizer; a resource policy exists, check that it restricts callers"];
else if (method.apiKeyRequired) [severity, reason] = ["MEDIUM", "API key only: identifies clients, doesn't authorize them"];
if (!stages.length) [severity, reason] = ["LOW", `${reason} (API has no stages, not deployed)`];
if (api.disableExecuteApiEndpoint) reason += "; default execute-api endpoint disabled";
findings.push({ Region: region, Api: name, Type: "REST", Method: verb, Path: path, Severity: severity, Reason: reason });
}
}
}
}
}
return findings;
}
async function allPages<T>(fetch: (token?: string) => Promise<{ Items?: T[]; NextToken?: string }>): Promise<T[]> {
const items: T[] = [];
let token: string | undefined;
do {
const out = await fetch(token);
items.push(...(out.Items ?? []));
token = out.NextToken;
} while (token);
return items;
}
async function scanV2(region: string): Promise<Finding[]> {
const client = new ApiGatewayV2Client({ region });
const findings: Finding[] = [];
const apis = await allPages<Api>((NextToken) => client.send(new GetApisCommand({ NextToken })));
for (const api of apis) {
if (!api.ApiId) continue;
const name = `${api.Name ?? "?"} (${api.ApiId})`;
const routes = await allPages<Route>((NextToken) => client.send(new GetRoutesCommand({ ApiId: api.ApiId, NextToken })));
const stages = await allPages((NextToken) => client.send(new GetV2StagesCommand({ ApiId: api.ApiId, NextToken })));
for (const route of routes) {
const key = route.RouteKey ?? "?";
const [verb, path = key] = key.includes(" ") ? key.split(" ") : ["ANY", key];
if (ignored.has(path)) continue;
if (verb === "OPTIONS" && !includeOptions) continue;
// WebSocket APIs authorize on $connect only; other routes always show NONE.
if (api.ProtocolType === "WEBSOCKET" && key !== "$connect") continue;
if ((route.AuthorizationType ?? "NONE") !== "NONE") continue;
let severity: Severity = "HIGH";
let reason = api.ProtocolType === "WEBSOCKET" ? "$connect has no authorizer: anyone can open a connection" : "no authorizer, and HTTP APIs have no resource policies";
if (!stages.length) [severity, reason] = ["LOW", `${reason} (API has no stages, not deployed)`];
if (api.DisableExecuteApiEndpoint) reason += "; default execute-api endpoint disabled";
findings.push({ Region: region, Api: name, Type: api.ProtocolType ?? "HTTP", Method: verb, Path: path, Severity: severity, Reason: reason });
}
}
return findings;
}
async function main(): Promise<void> {
const findings: Finding[] = [];
for (const region of await listRegions()) {
for (const scan of [scanRest, scanV2]) {
try {
findings.push(...(await scan(region)));
} catch (err) {
console.error(`${region} ${scan.name}: ${err instanceof Error ? err.name : String(err)}`);
}
}
}
const order: Record<Severity, number> = { HIGH: 0, MEDIUM: 1, LOW: 2, INFO: 3 };
findings.sort((a, b) => order[a.Severity] - order[b.Severity] || a.Api.localeCompare(b.Api) || a.Path.localeCompare(b.Path));
console.table(findings);
const high = findings.filter((f) => f.Severity === "HIGH").length;
console.log(`${findings.length} method(s)/route(s) without an authorizer; ${high} HIGH.`);
if (high) process.exitCode = 2;
}
main().catch((err) => {
console.error(err instanceof Error ? `${err.name}: ${err.message}` : err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-api-gateway @aws-sdk/client-apigatewayv2 @aws-sdk/client-ec2
npm install --save-dev tsx typescript
# Every enabled Region
AWS_PROFILE=security-audit npx tsx find-api-gateway-methods-without-authorization.ts
# Two Regions, ignoring paths that are public by design
AWS_PROFILE=security-audit npx tsx find-api-gateway-methods-without-authorization.ts --regions=us-east-1,eu-west-1 --ignore=/health,/status
--ignore matches the resource path of a REST method or the path part of an HTTP route key exactly. Keep that list short and reviewed, or it turns into a place to hide findings.
Sample output
┌─────────┬─────────────┬───────────────────────────────┬────────┬──────────┬────────────────┬──────────┬────────────────────────────────────────────────────────────────────────────────┐
│ (index) │ Region │ Api │ Type │ Method │ Path │ Severity │ Reason │
├─────────┼─────────────┼───────────────────────────────┼────────┼──────────┼────────────────┼──────────┼────────────────────────────────────────────────────────────────────────────────┤
│ 0 │ 'eu-west-1' │ 'orders-api (a1b2c3d4e5)' │ 'REST' │ 'DELETE' │ '/orders/{id}' │ 'HIGH' │ 'no authorizer: anyone who can reach the endpoint can call it' │
│ 1 │ 'us-east-1' │ 'webhooks (9zy8xw7vu6)' │ 'HTTP' │ 'POST' │ '/stripe' │ 'HIGH' │ 'no authorizer, and HTTP APIs have no resource policies' │
│ 2 │ 'us-east-1' │ 'partner-feed (k3l4m5n6o7)' │ 'REST' │ 'GET' │ '/feed' │ 'MEDIUM' │ "API key only: identifies clients, doesn't authorize them" │
│ 3 │ 'us-east-1' │ 'internal-admin (p8q9r0s1t2)' │ 'REST' │ 'POST' │ '/jobs' │ 'INFO' │ 'private API: reachable only through VPC endpoints its resource policy allows' │
└─────────┴─────────────┴───────────────────────────────┴────────┴──────────┴────────────────┴──────────┴────────────────────────────────────────────────────────────────────────────────┘
4 method(s)/route(s) without an authorizer; 2 HIGH.
The API names and IDs are illustrative. The DELETE /orders/{id} method is the one to fix first. The webhook route may be fine if the function verifies the provider’s signature, but that should be a deliberate, documented decision.
How do you add authorization to an open method?
- Callers inside AWS. Use
AWS_IAMand sign requests with SigV4; the caller needsexecute-api:Invokeon the method ARN. - Users of your app. Use a Cognito user pool authorizer on REST APIs or a
JWTauthorizer on HTTP APIs with your identity provider’s issuer and audience. - Custom tokens or headers. Use a Lambda authorizer (
CUSTOM). Keep it fast and cache results where the API type allows. - Internal-only APIs. Make a REST API private and allow only your VPC endpoints in its resource policy.
REST APIs serve a deployment snapshot, so after changing a method, deploy the API to each stage or the old, open configuration keeps running. GetResources shows the current configuration, not what’s deployed, so rerun the script after deploying. Once a method is locked down, turn on API Gateway access logging for every stage so you can see who calls it. To confirm a caller still works, see how to troubleshoot IAM access denied errors.
Troubleshooting
TooManyRequestsException. API Gateway rate-limits management calls such asGetResources. Scan fewer Regions per run or raise retries as in the guide to set retries and timeouts in AWS SDK v3.AccessDeniedExceptionin some Regions only. A service control policy may deny unused Regions. Pass the Regions you use with--regions=.- A method has an authorizer but is still reachable. Check stage deployments: the fix may not be deployed yet.
- Findings on a custom domain API you expected to be private. Custom domains don’t make an API private; only the
PRIVATEendpoint type and a resource policy do.
Ask ChatWithCloud instead
ChatWithCloud turns a question in plain English into AWS SDK for JavaScript v2 code, runs it on your machine with your profile, and hands the JSON result to the AI model to write the answer. “Which API Gateway methods in eu-west-1 have no authorizer?” reads the same resources. It uses one profile and Region per session, so ask per Region, and use the script to find API Gateway methods without authorization in all Regions at once. Code runs without a confirmation step; use a read-only AWS profile for ChatWithCloud and read how ChatWithCloud handles your data. The guide to analyze your AWS security posture with an AI CLI has more questions to try.
Frequently asked questions
How do I check if an API Gateway method requires authorization?
Run aws apigateway get-method --rest-api-id ID --resource-id RID --http-method GET and read authorizationType. NONE means no authorizer; for HTTP APIs use aws apigatewayv2 get-routes.
Is an API key enough to secure an API Gateway endpoint?
No. AWS says not to use API keys for authentication or authorization. Use IAM, a Lambda authorizer, Cognito or a JWT authorizer.
Can HTTP APIs have resource policies?
No. Resource policies and private endpoints are REST API features, so an HTTP API route without an authorizer is open to anyone who can reach it.
Should OPTIONS methods have an authorizer?
Usually not. Browsers send CORS preflight requests without credentials, so an authorizer on OPTIONS breaks cross-origin calls.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud