Check CloudTrail Is Enabled and Logging in Every AWS Region

A long server room corridor with rows of racks lit by white ceiling lights

Photo by panumas nikhomkhai on Pexels

To check CloudTrail is enabled in all Regions, call DescribeTrails in every enabled Region with shadow trails included, then GetTrailStatus with each trail’s ARN to confirm IsLogging is true there. Finish with GetEventSelectors to make sure the trail records both read and write management events. A Region is covered only when all three checks pass.

An account can have CloudTrail switched on and still miss most of its activity. A trail created with the AWS CLI or API is single-Region by default, so the Region you tested looks fine while the others record nothing beyond the 90-day event history. This example is for engineers who need to check CloudTrail is enabled in all Regions with evidence they can paste into an audit ticket, not a screenshot of one console page.

You’ll get a read-only TypeScript script for the AWS SDK for JavaScript v3 that prints one row per Region and one row per trail. It sits with the other AWS SDK v3 security audit scripts, and its exit code makes it easy to run on a schedule.

What counts as CloudTrail being on in a Region?

Having a trail isn’t enough. The script treats a Region as covered only when it can see all three of these:

  • A trail exists there. A multi-Region trail records events in every Region enabled in your account, and it shows up in each of them as a shadow trail. A single-Region trail covers only its home Region.
  • That trail is logging. StopLogging leaves the trail in place but stops recording. GetTrailStatus returns status for one Region at a time, so the script asks in each Region.
  • It records all management events. Event selectors can limit a trail to write-only events or exclude sources such as AWS KMS. The AWS Security Hub control CloudTrail.1 fails unless at least one multi-Region trail captures both read and write management events. Security Hub only runs that control in Regions where it’s enabled, which the script to check Security Hub is enabled in every Region confirms.

The same requirements appear in the CIS Amazon Web Services Foundations Benchmark: in version 5.0.0, recommendation 3.1 asks for CloudTrail in all Regions and 3.2 for log file validation. Recommendation 3.3 asks the same of AWS Config, which the script to check AWS Config is recording in every Region covers. The script reports validation per trail, because a trail that logs but can’t prove its files weren’t edited is a weaker audit record.

What does the script do?

  1. Lists RegionsDescribeRegions returns the Regions enabled for your account, or you pass --regions=.
  2. Finds trails per RegionDescribeTrails with includeShadowTrails: true, so multi-Region and organization trails created elsewhere are included.
  3. Checks logging in that RegionGetTrailStatus by trail ARN. The ARN is required for shadow trails and for organization trails seen from a member account.
  4. Reads event selectors once per trailGetEventSelectors in the trail’s home Region. It understands both basic and advanced event selectors.
  5. Prints two tables and sets the exit codeExit code 2 means at least one Region has a gap. Nothing is changed.

Prerequisites

Which IAM permissions does it need?

Four read-only actions. None of them can change a trail, so this policy is safe to attach to an audit role.

cloudtrail-audit-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadTrailCoverage",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeRegions",
        "cloudtrail:DescribeTrails",
        "cloudtrail:GetTrailStatus",
        "cloudtrail:GetEventSelectors"
      ],
      "Resource": "*"
    }
  ]
}

The free IAM policy generator for TypeScript SDK code produces a draft like this from the script itself. If an SCP blocks some Regions, those rows show an error instead of a verdict.

The full script to check CloudTrail is enabled in all Regions

check-cloudtrail-enabled-in-all-regions.ts

// check-cloudtrail-enabled-in-all-regions.ts
// Reports, for every enabled Region, whether at least one CloudTrail trail is logging there and
// records all read and write management events. Also lists each trail's multi-Region, organization
// and log file validation settings and its latest S3 delivery error. Read-only: it changes nothing.
// Usage: npx tsx check-cloudtrail-enabled-in-all-regions.ts [--regions=us-east-1,eu-west-1]
import { EC2Client, DescribeRegionsCommand } from "@aws-sdk/client-ec2";
import {
  CloudTrailClient,
  DescribeTrailsCommand,
  GetEventSelectorsCommand,
  GetTrailStatusCommand,
  type GetEventSelectorsCommandOutput,
} from "@aws-sdk/client-cloudtrail";

const regionArg = process.argv.slice(2).find((a) => a.startsWith("--regions="))?.split("=")[1];

type Mgmt = "all" | "partial" | "none" | "unknown";
interface TrailInfo { arn: string; name: string; home: string; multi: boolean; org: boolean; validation: boolean; mgmt: Mgmt }
interface RegionRow { Region: string; Trails: string; Logging: string; Management: string; Verdict: string }
interface TrailRow { Trail: string; Home: string; MultiRegion: boolean; Org: boolean; Validation: boolean; Management: Mgmt; LastDeliveryError: string }

async function listRegions(): Promise<string[]> {
  if (regionArg) return regionArg.split(",").map((r) => r.trim()).filter(Boolean);
  const out = await new EC2Client({}).send(new DescribeRegionsCommand({})); // enabled Regions only
  return (out.Regions ?? []).map((r) => r.RegionName ?? "").filter(Boolean).sort();
}

// "all" = read and write management events with no excluded event sources.
function managementCoverage(sel: GetEventSelectorsCommandOutput): Mgmt {
  let found: Mgmt = "none";
  if (sel.AdvancedEventSelectors?.length) {
    for (const s of sel.AdvancedEventSelectors) {
      const fields = s.FieldSelectors ?? [];
      if (!fields.find((f) => f.Field === "eventCategory")?.Equals?.includes("Management")) continue;
      const readOnlyFilter = fields.some((f) => f.Field === "readOnly");
      const excludesSources = fields.some((f) => f.Field === "eventSource" && (f.NotEquals?.length ?? 0) > 0);
      if (!readOnlyFilter && !excludesSources) return "all";
      found = "partial";
    }
    return found;
  }
  for (const s of sel.EventSelectors ?? []) {
    if (!s.IncludeManagementEvents) continue;
    if (s.ReadWriteType === "All" && !(s.ExcludeManagementEventSources?.length)) return "all";
    found = "partial";
  }
  return found;
}

const selectorCache = new Map<string, Mgmt>();
async function selectorsFor(arn: string, home: string): Promise<Mgmt> {
  const cached = selectorCache.get(arn);
  if (cached) return cached;
  let result: Mgmt;
  try {
    // Event selectors are read in the trail's home Region, by ARN (required for organization trails).
    result = managementCoverage(await new CloudTrailClient({ region: home }).send(new GetEventSelectorsCommand({ TrailName: arn })));
  } catch {
    result = "unknown";
  }
  selectorCache.set(arn, result);
  return result;
}

async function main(): Promise<void> {
  const regionRows: RegionRow[] = [];
  const trails = new Map<string, TrailInfo & { lastError: string }>();

  for (const region of await listRegions()) {
    const ct = new CloudTrailClient({ region });
    try {
      // includeShadowTrails returns multi-Region and organization trails that were created elsewhere.
      const list = (await ct.send(new DescribeTrailsCommand({ includeShadowTrails: true }))).trailList ?? [];
      const names: string[] = [];
      let logging = false;
      let fullMgmt = false;
      let unreadable = false;
      for (const t of list) {
        const arn = t.TrailARN ?? "";
        const home = t.HomeRegion ?? region;
        const status = await ct.send(new GetTrailStatusCommand({ Name: arn })); // status as seen in this Region
        const mgmt = await selectorsFor(arn, home);
        names.push(t.Name ?? arn);
        if (status.IsLogging) {
          logging = true;
          if (mgmt === "all") fullMgmt = true;
          if (mgmt === "unknown") unreadable = true;
        }
        if (!trails.has(arn)) {
          trails.set(arn, {
            arn, name: t.Name ?? "", home, mgmt,
            multi: t.IsMultiRegionTrail === true,
            org: t.IsOrganizationTrail === true,
            validation: t.LogFileValidationEnabled === true,
            lastError: status.LatestDeliveryError ?? "",
          });
        }
      }
      const verdict = !list.length ? "NO TRAIL"
        : !logging ? "TRAILS EXIST BUT NONE LOGGING"
        : fullMgmt ? "ok"
        : unreadable ? "logging; check selectors in the home account"
        : "logging, but not all management events";
      const management = fullMgmt ? "read+write" : unreadable ? "unknown" : "incomplete";
      regionRows.push({ Region: region, Trails: names.join(", ") || "-", Logging: logging ? "yes" : "NO", Management: management, Verdict: verdict });
    } catch (err) {
      regionRows.push({ Region: region, Trails: "?", Logging: "?", Management: "?", Verdict: `error: ${err instanceof Error ? err.name : String(err)}` });
    }
  }

  console.table(regionRows);
  const trailRows: TrailRow[] = [...trails.values()].map((t) => ({
    Trail: t.name, Home: t.home, MultiRegion: t.multi, Org: t.org, Validation: t.validation, Management: t.mgmt, LastDeliveryError: t.lastError || "-",
  }));
  console.table(trailRows);

  const gaps = regionRows.filter((r) => r.Verdict !== "ok");
  const noValidation = trailRows.filter((t) => !t.Validation).length;
  console.log(`${regionRows.length} Regions checked; ${gaps.length} without a logging trail that records all management events.`);
  if (noValidation) console.log(`${noValidation} trail(s) without log file validation.`);
  console.log("Read-only: nothing was changed.");
  if (gaps.length) process.exitCode = 2; // lets a CI job or cron wrapper fail on a gap
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

A selector that splits read and write events into two advanced selectors is reported as partial, which is the conservative answer. Check that trail by hand if the verdict surprises you.

How do you run it?

Terminal

npm install @aws-sdk/client-cloudtrail @aws-sdk/client-ec2
npm install --save-dev tsx typescript

# Every enabled Region
AWS_PROFILE=readonly npx tsx check-cloudtrail-enabled-in-all-regions.ts

# A few Regions, failing the shell step if any has a gap
AWS_PROFILE=readonly npx tsx check-cloudtrail-enabled-in-all-regions.ts --regions=us-east-1,eu-west-1 || echo "CloudTrail gap found"

Sample output

Output

┌─────────┬──────────────────┬────────────────┬─────────┬──────────────┬──────────────────────────────────────────┐
│ (index) │ Region           │ Trails         │ Logging │ Management   │ Verdict                                  │
├─────────┼──────────────────┼────────────────┼─────────┼──────────────┼──────────────────────────────────────────┤
│ 0       │ 'ap-southeast-2' │ '-'            │ 'NO'    │ 'incomplete' │ 'NO TRAIL'                               │
│ 1       │ 'eu-west-1'      │ 'eu-app-trail' │ 'yes'   │ 'incomplete' │ 'logging, but not all management events' │
│ 2       │ 'us-east-1'      │ 'main-trail'   │ 'yes'   │ 'read+write' │ 'ok'                                     │
│ 3       │ 'us-west-2'      │ '-'            │ 'NO'    │ 'incomplete' │ 'NO TRAIL'                               │
└─────────┴──────────────────┴────────────────┴─────────┴──────────────┴──────────────────────────────────────────┘
┌─────────┬────────────────┬─────────────┬─────────────┬───────┬────────────┬────────────┬───────────────────┐
│ (index) │ Trail          │ Home        │ MultiRegion │ Org   │ Validation │ Management │ LastDeliveryError │
├─────────┼────────────────┼─────────────┼─────────────┼───────┼────────────┼────────────┼───────────────────┤
│ 0       │ 'main-trail'   │ 'us-east-1' │ false       │ false │ true       │ 'all'      │ '-'               │
│ 1       │ 'eu-app-trail' │ 'eu-west-1' │ false       │ false │ false      │ 'partial'  │ 'AccessDenied'    │
└─────────┴────────────────┴─────────────┴─────────────┴───────┴────────────┴────────────┴───────────────────┘
4 Regions checked; 3 without a logging trail that records all management events.
1 trail(s) without log file validation.
Read-only: nothing was changed.

Trail names are illustrative, and the pattern is common: both trails were created from the CLI, so each is single-Region. us-west-2 and ap-southeast-2 record nothing beyond event history, and eu-app-trail uses a write-only selector and has an S3 delivery error.

How do you fix a gap?

The script is report-only on purpose: changing trails touches S3 bucket policies, KMS keys and organization settings, and those need a person to decide. The usual fixes:

  • Turn a single-Region trail into a multi-Region one. UpdateTrail with IsMultiRegionTrail set to true, called in the trail’s home Region. You can modify a multi-Region trail only in its home Region. Multi-Region trails also require IncludeGlobalServiceEvents, which captures IAM, STS and CloudFront events recorded in us-east-1.
  • Record read and write events. Set ReadWriteType to All with PutEventSelectors, and remove excluded management event sources unless you’ve decided you don’t need them. CloudTrail doesn’t record requests to your own APIs; for those, turn on API Gateway access logging for every stage.
  • Fix LatestDeliveryError. This field shows an S3 error, usually a bucket policy that doesn’t let CloudTrail write. While it’s set, events aren’t landing in your bucket. The script to find public and private S3 buckets with the AWS SDK also confirms the log bucket isn’t public, and the one to find S3 buckets without versioning enabled shows whether deleted log files could be recovered.
  • Turn on log file validation. UpdateTrail with EnableLogFileValidation. CloudTrail then delivers signed digest files containing a hash of each log file, which let you prove a file wasn’t changed or deleted after delivery.

What does a multi-Region trail cost?

As of September 2026, the AWS CloudTrail pricing page lists event history (the last 90 days of management events) at no charge, and the first copy of management events delivered to S3 by a trail is also free. Each additional copy costs $2.00 per 100,000 management events. So converting your one trail to multi-Region adds no CloudTrail charge; a second trail that records the same management events does. You still pay S3 storage for the log files, and CloudWatch Logs charges apply if you stream the trail there, which is where the script to set CloudWatch log retention for all log groups keeps the cost bounded.

What about organization trails?

An organization trail created from the management account delivers events for every member account. Members see it in DescribeTrails but can’t stop logging or delete it, and they must use its ARN, not its name. Two edge cases: if the trail’s home Region is an opt-in Region, only member accounts that enabled that Region send events; and a member account may not be allowed to read the trail’s event selectors, which the script reports as unknown rather than a failure. Run it from the management account for the final answer.

Troubleshooting

  • AccessDeniedException on every Region. The profile lacks cloudtrail:DescribeTrails. The guide to troubleshoot AWS IAM access denied errors step by step walks through finding which policy or SCP denies it.
  • TrailNotFoundException on GetTrailStatus. Something passed a trail name instead of an ARN for a shadow trail. The script always uses TrailARN; if you adapt it, keep that.
  • A Region you never use shows NO TRAIL. That’s the finding. Unused Regions are exactly where unexpected activity goes unnoticed, which is why the benchmark asks for all of them.
  • A new opt-in Region is missing from the output. DescribeRegions returns only Regions enabled for the account. Once you enable one, a multi-Region trail starts recording there.

CloudTrail tells you who did something; the scripts to find IAM users without MFA and find IAM access keys older than 90 days or never used reduce who can do it in the first place. GuardDuty reads the same management events to flag suspicious use, so pair this report with the script to check GuardDuty is enabled in every AWS Region.

Ask ChatWithCloud instead

You can also ask ChatWithCloud “Is there a CloudTrail trail logging in every Region?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile and explains the result; the guide to analyze your AWS security posture with an AI CLI shows the wider set of questions. It works in one profile and Region per session and runs generated code without a confirmation step, so connect ChatWithCloud to your AWS account with a read-only profile. The ChatWithCloud security model explains what stays on your machine.

Frequently asked questions

How do I know if CloudTrail is enabled in all Regions?

Look for a trail with IsMultiRegionTrail set to true, then confirm with GetTrailStatus in each Region that it’s logging and with GetEventSelectors that it records read and write management events.

Is CloudTrail enabled by default?

Event history is: it keeps the last 90 days of management events in each Region at no charge. A trail, which delivers events to S3 for longer retention, has to be created.

Are trails created with the AWS CLI multi-Region?

No. The CLI and API create single-Region trails by default; trails created in the CloudTrail console are multi-Region.

How many trails can I have per Region?

Five. A multi-Region trail counts as one trail in every Region.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud