Find API Gateway Stages Without Access Logging

An open paper ledger with handwritten rows of entries on a wooden desk

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?

  1. Lists RegionsDescribeRegions, or --regions=.
  2. Checks the CloudWatch roleGetAccount returns cloudwatchRoleArn for the Region.
  3. Reads REST stagespaginateGetRestApis, then GetStages: accessLogSettings, methodSettings["*/*"] for the stage-wide logging level and data tracing, and tracingEnabled.
  4. Reads HTTP and WebSocket stagesGetApis and GetStages from the V2 API: AccessLogSettings, and DefaultRouteSettings for WebSocket logging levels.
  5. Enables access logs on requestWith --apply: creates /aws/apigateway/access/{api-id}/{stage} with a retention period, then calls UpdateStage with 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

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.

api-gateway-logging-policy.json

{
  "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

// 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?

Terminal

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

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 BadRequestException about 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.requestId or $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