Photo by Vitaly Gariev on Unsplash
API Gateway access logging is a per-stage setting: a stage logs requests only when its accessLogSettings has a destination ARN and a format. To find stages without it, call GetStages for each REST API and each HTTP or WebSocket API, and flag stages with no destination. Enable it with UpdateStage, pointing at a CloudWatch Logs log group.
When an API misbehaves or someone abuses it, the first question is who called what, from where, and what they got back. API Gateway answers that only if access logging was on before the incident; it’s off by default on new stages. This example is for engineers who want to audit API Gateway access logging on every stage in every Region, and turn it on where it’s missing.
The TypeScript script uses the AWS SDK for JavaScript v3. It reports by default and changes stages only when you pass --apply. It also shows execution logging, full data tracing and X-Ray, because those three are often confused with access logs.
Access logs, execution logs and tracing: what’s the difference?
| Setting | What it records | API types |
|---|---|---|
| Access logging | One line per request in a format you choose from $context variables: caller IP, method, path, status, latency |
REST, HTTP, WebSocket |
Execution logging (ERROR or INFO) |
What API Gateway did to process the request: authorizer results, errors, integration calls | REST, WebSocket |
| Data tracing | Full request and response bodies in the execution log | REST, WebSocket |
| X-Ray tracing | A trace segment per request | REST only |
Access logs are the audit trail; execution logs are for debugging. API Gateway writes standard REST execution logs to a log group it manages, named API-Gateway-Execution-Logs_{rest-api-id}/{stage_name}, and truncates each event at 1 KB. It redacts authorization headers and API key values from execution logs, but data tracing can still capture sensitive payloads, and AWS recommends against it in production. The script flags data tracing as ON in capitals for that reason.
Deciding what to log is its own topic. The OWASP Logging Cheat Sheet covers which fields help an investigation and which personal data to keep out of logs.
Why do REST API logs need an account-level role?
For REST APIs, API Gateway writes to CloudWatch Logs by assuming an IAM role that you set once per Region on the API Gateway account settings (cloudwatchRoleArn). The role trusts apigateway.amazonaws.com and uses the AmazonAPIGatewayPushToCloudWatchLogs managed policy. Without it, REST access and execution logging can’t be turned on. AWS’s steps for HTTP API logging don’t involve that role; instead, whoever turns logging on needs CloudWatch Logs delivery permissions. The script reads GetAccount in each Region and tells you where the role is missing, but doesn’t create it.
What does the script do?
- Lists Regions
DescribeRegions, or--regions=. - Checks the CloudWatch role
GetAccountreturnscloudwatchRoleArnfor the Region. - Reads REST stages
paginateGetRestApis, thenGetStages:accessLogSettings,methodSettings["*/*"]for the stage-wide logging level and data tracing, andtracingEnabled. - Reads HTTP and WebSocket stages
GetApisandGetStagesfrom the V2 API:AccessLogSettings, andDefaultRouteSettingsfor WebSocket logging levels. - Enables access logs on requestWith
--apply: creates/aws/apigateway/access/{api-id}/{stage}with a retention period, then callsUpdateStagewith a JSON format that includes$context.requestId. REST stages are skipped where the Region has no CloudWatch role; WebSocket stages are left for you.
Prerequisites
- Node.js 18 or later, npm and
tsx. - The packages
@aws-sdk/client-api-gateway,@aws-sdk/client-apigatewayv2,@aws-sdk/client-cloudwatch-logs,@aws-sdk/client-ec2and@aws-sdk/client-sts. - A profile set up as in the guide to AWS SDK v3 credential providers such as fromIni and fromSSO.
Which IAM permissions does it need?
Replace the account ID. The first two statements are enough for the report; the last three are for --apply only. The log delivery actions are the ones AWS lists for turning on HTTP API logging.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListRegions",
"Effect": "Allow",
"Action": "ec2:DescribeRegions",
"Resource": "*"
},
{
"Sid": "ReadApiGateway",
"Effect": "Allow",
"Action": "apigateway:GET",
"Resource": [
"arn:aws:apigateway:*::/account",
"arn:aws:apigateway:*::/restapis",
"arn:aws:apigateway:*::/restapis/*",
"arn:aws:apigateway:*::/apis",
"arn:aws:apigateway:*::/apis/*"
]
},
{
"Sid": "UpdateStagesApplyOnly",
"Effect": "Allow",
"Action": "apigateway:PATCH",
"Resource": [
"arn:aws:apigateway:*::/restapis/*/stages/*",
"arn:aws:apigateway:*::/apis/*/stages/*"
]
},
{
"Sid": "CreateLogGroupsApplyOnly",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:PutRetentionPolicy"
],
"Resource": "arn:aws:logs:*:111122223333:log-group:/aws/apigateway/access/*"
},
{
"Sid": "HttpApiLogDeliveryApplyOnly",
"Effect": "Allow",
"Action": [
"logs:CreateLogDelivery",
"logs:GetLogDelivery",
"logs:UpdateLogDelivery",
"logs:DeleteLogDelivery",
"logs:ListLogDeliveries",
"logs:PutResourcePolicy",
"logs:DescribeResourcePolicies",
"logs:DescribeLogGroups"
],
"Resource": "*"
}
]
}
The script to audit and enable API Gateway access logging
// find-api-gateway-stages-without-logging.ts
// Reports every API Gateway stage (REST, HTTP and WebSocket) in each Region with its access logging,
// execution logging level, data tracing and X-Ray tracing, plus whether the Region has the CloudWatch
// role REST APIs need. Dry run by default: with --apply it creates a log group per stage and turns on
// access logging for REST and HTTP API stages that have none.
// Usage: npx tsx find-api-gateway-stages-without-logging.ts [--regions=us-east-1] [--retention=90] [--apply]
import { EC2Client, DescribeRegionsCommand } from "@aws-sdk/client-ec2";
import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";
import {
APIGatewayClient,
GetAccountCommand,
paginateGetRestApis,
GetStagesCommand,
UpdateStageCommand,
} from "@aws-sdk/client-api-gateway";
import {
ApiGatewayV2Client,
GetApisCommand,
GetStagesCommand as GetV2StagesCommand,
UpdateStageCommand as UpdateV2StageCommand,
type Api,
type Stage as V2Stage,
} from "@aws-sdk/client-apigatewayv2";
import { CloudWatchLogsClient, CreateLogGroupCommand, PutRetentionPolicyCommand } from "@aws-sdk/client-cloudwatch-logs";
const args = process.argv.slice(2);
const apply = args.includes("--apply");
const regionArg = args.find((a) => a.startsWith("--regions="))?.split("=")[1]?.split(",").map((s) => s.trim()).filter(Boolean);
const retention = Number(args.find((a) => a.startsWith("--retention="))?.split("=")[1] ?? "90");
const ALLOWED_RETENTION = [1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653];
if (!ALLOWED_RETENTION.includes(retention)) {
console.error(`--retention must be one of ${ALLOWED_RETENTION.join(", ")}`);
process.exit(1);
}
// Single-line JSON formats; both include $context.requestId, which REST access logs require.
const REST_FORMAT = JSON.stringify({
requestId: "$context.requestId", extendedRequestId: "$context.extendedRequestId", ip: "$context.identity.sourceIp",
requestTime: "$context.requestTime", httpMethod: "$context.httpMethod", resourcePath: "$context.resourcePath",
status: "$context.status", protocol: "$context.protocol", responseLength: "$context.responseLength",
});
const HTTP_FORMAT = JSON.stringify({
requestId: "$context.requestId", ip: "$context.identity.sourceIp", requestTime: "$context.requestTime",
httpMethod: "$context.httpMethod", routeKey: "$context.routeKey", status: "$context.status",
protocol: "$context.protocol", responseLength: "$context.responseLength",
});
interface Row {
Region: string;
Api: string;
Type: string;
Stage: string;
AccessLog: string;
ExecLog: string;
DataTrace: string;
XRay: string;
Note: 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();
}
// Log group names allow letters, digits and _ - / . # only; "$default" becomes "_default".
const logGroupName = (apiId: string, stage: string) => `/aws/apigateway/access/${apiId}/${stage.replace(/[^\w\-/.#]/g, "_")}`;
async function ensureLogGroup(logs: CloudWatchLogsClient, name: string): Promise<void> {
try {
await logs.send(new CreateLogGroupCommand({ logGroupName: name }));
} catch (err) {
if (!(err instanceof Error && err.name === "ResourceAlreadyExistsException")) throw err;
}
await logs.send(new PutRetentionPolicyCommand({ logGroupName: name, retentionInDays: retention }));
}
async function allV2<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 scanRegion(region: string, account: string): Promise<Row[]> {
const rest = new APIGatewayClient({ region });
const v2 = new ApiGatewayV2Client({ region });
const logs = new CloudWatchLogsClient({ region });
const roleArn = (await rest.send(new GetAccountCommand({}))).cloudwatchRoleArn;
const arnFor = (name: string) => `arn:aws:logs:${region}:${account}:log-group:${name}`;
const rows: Row[] = [];
for await (const page of paginateGetRestApis({ client: rest }, { limit: 500 })) {
for (const api of page.items ?? []) {
if (!api.id) continue;
for (const stage of (await rest.send(new GetStagesCommand({ restApiId: api.id }))).item ?? []) {
const all = stage.methodSettings?.["*/*"];
const row: Row = {
Region: region, Api: `${api.name ?? "?"} (${api.id})`, Type: "REST", Stage: stage.stageName ?? "?",
AccessLog: stage.accessLogSettings?.destinationArn ? "on" : "OFF",
ExecLog: all?.loggingLevel ?? "OFF", DataTrace: all?.dataTraceEnabled ? "ON" : "off",
XRay: stage.tracingEnabled ? "on" : "off", Note: roleArn ? "" : "no CloudWatch role set for this Region",
};
if (apply && row.AccessLog === "OFF") {
if (!roleArn) {
row.Note = "skipped: set the API Gateway CloudWatch role for this Region first";
} else {
try {
const name = logGroupName(api.id, row.Stage);
await ensureLogGroup(logs, name);
await rest.send(new UpdateStageCommand({
restApiId: api.id, stageName: row.Stage,
patchOperations: [
{ op: "replace", path: "/accessLogSettings/destinationArn", value: arnFor(name) },
{ op: "replace", path: "/accessLogSettings/format", value: REST_FORMAT },
],
}));
[row.AccessLog, row.Note] = ["on", `enabled now -> ${name}`];
} catch (err) {
row.Note = `enable failed: ${err instanceof Error ? err.name : String(err)}`;
}
}
}
rows.push(row);
}
}
}
const apis = await allV2<Api>((NextToken) => v2.send(new GetApisCommand({ NextToken })));
for (const api of apis) {
if (!api.ApiId) continue;
const apiId = api.ApiId;
const stages = await allV2<V2Stage>((NextToken) => v2.send(new GetV2StagesCommand({ ApiId: apiId, NextToken })));
for (const stage of stages) {
const ws = api.ProtocolType === "WEBSOCKET";
const row: Row = {
Region: region, Api: `${api.Name ?? "?"} (${apiId})`, Type: api.ProtocolType ?? "HTTP", Stage: stage.StageName ?? "?",
AccessLog: stage.AccessLogSettings?.DestinationArn ? "on" : "OFF",
ExecLog: ws ? stage.DefaultRouteSettings?.LoggingLevel ?? "OFF" : "n/a",
DataTrace: ws && stage.DefaultRouteSettings?.DataTraceEnabled ? "ON" : ws ? "off" : "n/a",
XRay: "n/a", Note: "",
};
if (apply && row.AccessLog === "OFF") {
if (ws) {
row.Note = "skipped: configure WebSocket logging by hand";
} else {
try {
const name = logGroupName(apiId, row.Stage);
await ensureLogGroup(logs, name);
await v2.send(new UpdateV2StageCommand({
ApiId: apiId, StageName: row.Stage,
AccessLogSettings: { DestinationArn: arnFor(name), Format: HTTP_FORMAT },
}));
[row.AccessLog, row.Note] = ["on", `enabled now -> ${name}`];
} catch (err) {
row.Note = `enable failed: ${err instanceof Error ? err.name : String(err)}`;
}
}
}
rows.push(row);
}
}
return rows;
}
async function main(): Promise<void> {
const account = (await new STSClient({}).send(new GetCallerIdentityCommand({}))).Account ?? "";
const rows: Row[] = [];
for (const region of await listRegions()) {
try {
rows.push(...(await scanRegion(region, account)));
} catch (err) {
console.error(`${region}: ${err instanceof Error ? err.name : String(err)}`);
}
}
rows.sort((a, b) => Number(b.AccessLog === "OFF") - Number(a.AccessLog === "OFF") || a.Region.localeCompare(b.Region));
console.table(rows);
const off = rows.filter((r) => r.AccessLog === "OFF").length;
const traced = rows.filter((r) => r.DataTrace === "ON").length;
console.log(`${rows.length} stage(s): ${off} without access logging, ${traced} with full request/response data tracing.`);
if (!apply && off) {
console.log(`Dry run. Re-run with --apply to create log groups (${retention}-day retention) and turn on access logging.`);
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-cloudwatch-logs @aws-sdk/client-ec2 @aws-sdk/client-sts
npm install --save-dev tsx typescript
# Report every enabled Region (dry run)
AWS_PROFILE=security-audit npx tsx find-api-gateway-stages-without-logging.ts
# Turn on access logs in two Regions, keeping logs for 180 days
AWS_PROFILE=platform-admin npx tsx find-api-gateway-stages-without-logging.ts --regions=us-east-1,eu-west-1 --retention=180 --apply
Stage settings take effect without redeploying the API. --retention accepts the values CloudWatch Logs allows, such as 30, 90, 180 or 365 days; for existing groups with no expiry, see the script to set CloudWatch Logs retention for all log groups.
Sample output
┌─────────┬─────────────┬─────────────────────────────┬─────────────┬────────────┬───────────┬─────────┬───────────┬───────┬──────┐
│ (index) │ Region │ Api │ Type │ Stage │ AccessLog │ ExecLog │ DataTrace │ XRay │ Note │
├─────────┼─────────────┼─────────────────────────────┼─────────────┼────────────┼───────────┼─────────┼───────────┼───────┼──────┤
│ 0 │ 'eu-west-1' │ 'orders-api (a1b2c3d4e5)' │ 'REST' │ 'prod' │ 'OFF' │ 'ERROR' │ 'off' │ 'off' │ '' │
│ 1 │ 'us-east-1' │ 'webhooks (9zy8xw7vu6)' │ 'HTTP' │ '$default' │ 'OFF' │ 'n/a' │ 'n/a' │ 'n/a' │ '' │
│ 2 │ 'us-east-1' │ 'chat (w1x2y3z4a5)' │ 'WEBSOCKET' │ 'prod' │ 'OFF' │ 'OFF' │ 'off' │ 'n/a' │ '' │
│ 3 │ 'us-east-1' │ 'partner-feed (k3l4m5n6o7)' │ 'REST' │ 'v1' │ 'on' │ 'INFO' │ 'ON' │ 'on' │ '' │
└─────────┴─────────────┴─────────────────────────────┴─────────────┴────────────┴───────────┴─────────┴───────────┴───────┴──────┘
4 stage(s): 3 without access logging, 1 with full request/response data tracing.
Dry run. Re-run with --apply to create log groups (90-day retention) and turn on access logging.
The names are illustrative. orders-api/prod has execution errors logged but no record of who called it. partner-feed/v1 has the opposite problem: full request and response bodies in its execution log, which may include personal data.
What does API Gateway access logging cost?
Access logs are billed as CloudWatch Logs data. From the Amazon CloudWatch pricing page and the AWS Price List for US East (N. Virginia), checked September 2026:
| Item | Price |
|---|---|
| Ingestion, Standard log class | $0.50 per GB |
| Storage | $0.03 per GB-month |
A JSON line with the fields above is roughly 400 bytes; measure yours. At 10 million requests a month that’s 10,000,000 × 400 bytes = 4 GB, so 4 × $0.50 = $2.00 a month to ingest, plus 4 × $0.03 = $0.12 for each month you keep it. Busy APIs scale linearly, so drop fields you never query, and let the retention period cap storage. The script to get the total cost of CloudWatch for the current month shows what you actually pay.
Troubleshooting
- A
BadRequestExceptionabout the CloudWatch Logs role ARN. Set the API Gateway CloudWatch role for that Region, then rerun. - Access log format rejected. REST access log formats must include
$context.requestIdor$context.extendedRequestId, and must be a single line. - HTTP API update fails with
AccessDeniedException. The caller lacks the log delivery permissions in the last statement. - No log lines appear. Send a request to the stage and wait a minute. If you use a custom domain, confirm it maps to the stage you changed. The guide to troubleshoot AWS infrastructure with an AI CLI helps narrow down the rest.
API Gateway access logs record data plane requests. Changes to the APIs themselves, such as someone removing an authorizer, show up in CloudTrail; use the script to check CloudTrail is enabled in every Region for that side. Methods that never had an authorizer look normal in both logs, so also run the script to find API Gateway methods without authorization. For network-level records, the script to find VPCs without flow logs is the counterpart.
Ask ChatWithCloud instead
ChatWithCloud turns plain-English questions into AWS SDK for JavaScript v2 code, runs it locally with your AWS profile and sends the JSON result to the AI model for the answer. “Which API Gateway stages in us-east-1 have no access logging?” reads the same stage settings. It works in one profile and Region per session, so it’s good for spot-checking API Gateway access logging in one Region. Generated code runs without a confirmation step, so a request to “turn on access logs” would change stages immediately; explore with a read-only AWS profile for ChatWithCloud and read the ChatWithCloud security model first.
Frequently asked questions
How do I enable access logging for API Gateway?
Create a CloudWatch Logs log group, then set the stage’s access log destination to its ARN and choose a format. For REST APIs, the Region’s API Gateway CloudWatch role must be set first.
What is the difference between API Gateway access logs and execution logs?
Access logs record one line per request in your format. Execution logs record what API Gateway did while processing it and exist for REST and WebSocket APIs only.
Do I need to redeploy the API after enabling logging?
No. Logging is a stage setting and takes effect without a new deployment.
Can API Gateway send access logs to Firehose?
REST APIs can, to a Firehose stream whose name starts with amazon-apigateway-. HTTP APIs log to CloudWatch Logs only.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud