Back to blog
AWSBest PracticesCloud SecurityMonitoring & LoggingOperations & Compliance

CloudTrail Not Sending to CloudWatch: Why It Matters and How to Fix It

Learn why CloudTrail trails should deliver to CloudWatch Logs, the attack risks of skipping it, and step-by-step CLI, console, and Terraform fixes.

TL;DR

This check flags CloudTrail trails that deliver logs only to S3 and skip CloudWatch Logs, which leaves you without real-time monitoring or metric-based alerting on API activity. Fix it by attaching a CloudWatch Logs group and IAM role to the trail with aws cloudtrail update-trail.

CloudTrail is the backbone of audit logging in AWS. Every API call, console action, and service event flows through it. By default though, a trail just dumps those events into an S3 bucket. That is fine for long-term storage and forensics, but it does nothing for you in the moment an attacker disables a security group or spins up crypto-mining instances at 3am.

Sending CloudTrail to CloudWatch Logs is what turns passive log storage into active detection. This check catches trails that are missing that link.


What this check detects

The account_cloudtraillogs check inspects each CloudTrail trail in your account and verifies that it has a CloudWatch Logs group configured as a delivery target. Specifically, it looks at whether the trail has a populated CloudWatchLogsLogGroupArn and an associated CloudWatchLogsRoleArn.

If a trail writes only to S3 and has no CloudWatch Logs integration, the check fails.

Note: S3 delivery and CloudWatch Logs delivery are not mutually exclusive. A properly configured trail sends events to both: S3 for durable, cost-effective archival and CloudWatch Logs for near-real-time querying and alerting. This check is not asking you to replace S3, just to add CloudWatch alongside it.


Why it matters

Logs sitting in an S3 bucket are only useful if someone goes looking for them. The gap between an event happening and a human noticing it can be days or weeks when your only source is object storage. CloudWatch Logs closes that gap.

Here is what you lose without the CloudWatch integration:

  • Metric filters and alarms. You cannot create a CloudWatch metric filter that fires an alarm when, say, DeleteTrail or StopLogging is called. These are classic anti-forensic moves an attacker makes right after gaining access.
  • Real-time queries. CloudWatch Logs Insights lets you run ad-hoc queries across recent activity in seconds. Querying raw CloudTrail JSON in S3 means standing up Athena or pulling files manually.
  • EventBridge and Lambda triggers. Subscription filters on a log group can stream events to Lambda for automated response, like auto-reverting a public S3 bucket policy.
  • Compliance evidence. CIS AWS Foundations Benchmark control 3.x and many SOC 2 audits expect CloudTrail to feed CloudWatch with active monitoring on top.

A concrete attack scenario

An attacker compromises a developer's access keys that were committed to a public repo. The first thing a competent attacker does is reconnaissance, then they try to blind your detection. They call StopLogging on your trail. If that event only lands in S3, nothing happens. Nobody is alerted. The attacker proceeds to exfiltrate data with the logging turned off.

With CloudWatch Logs in place, a metric filter on StopLogging trips a CloudWatch alarm within a minute, pages your on-call, and you start incident response while the attacker is still poking around.

Logging you never look at is just a storage bill. The value of an audit trail is proportional to how fast you can act on it.


How to fix it

You need three things: a CloudWatch Logs group to receive the events, an IAM role that lets CloudTrail write to that group, and an update to the trail pointing at both.

Step 1: Create the CloudWatch Logs group

aws logs create-log-group \
  --log-group-name /aws/cloudtrail/management-events

# Optional but recommended: set a retention period
aws logs put-retention-policy \
  --log-group-name /aws/cloudtrail/management-events \
  --retention-in-days 365

Step 2: Create the IAM role and policy for CloudTrail

CloudTrail needs permission to create log streams and put events into your group. First the trust policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "cloudtrail.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }
  ]
}
aws iam create-role \
  --role-name CloudTrail_CloudWatchLogs_Role \
  --assume-role-policy-document file://trust-policy.json

Now the permissions policy. Replace the account ID, region, and log group name to match yours:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AWSCloudTrailCreateLogStream",
      "Effect": "Allow",
      "Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": "arn:aws:logs:us-east-1:111122223333:log-group:/aws/cloudtrail/management-events:log-stream:*"
    }
  ]
}
aws iam put-role-policy \
  --role-name CloudTrail_CloudWatchLogs_Role \
  --policy-name CloudTrail_CloudWatchLogs_Policy \
  --policy-document file://permissions-policy.json

Step 3: Update the trail

Warning: CloudWatch Logs ingestion is billed per GB ingested and stored. High-volume accounts with data events enabled can generate significant log volume. Set a retention policy and consider filtering to management events only for the CloudWatch target if cost is a concern.

aws cloudtrail update-trail \
  --name my-org-trail \
  --cloud-watch-logs-log-group-arn arn:aws:logs:us-east-1:111122223333:log-group:/aws/cloudtrail/management-events:* \
  --cloud-watch-logs-role-arn arn:aws:iam::111122223333:role/CloudTrail_CloudWatchLogs_Role

Verify it took effect:

aws cloudtrail get-trail --name my-org-trail \
  --query 'Trail.[CloudWatchLogsLogGroupArn,CloudWatchLogsRoleArn]'

Console steps

  1. Open the CloudTrail console and select your trail.
  2. Under CloudWatch Logs, click Edit.
  3. Toggle Enabled, then choose to create a new log group or select an existing one.
  4. Let CloudTrail create the IAM role for you, or point it at the role you made above.
  5. Save changes. Events begin flowing within a few minutes.

Doing it with infrastructure as code

Manual fixes drift. Defining the trail, log group, and role in code keeps the integration intact and reproducible across accounts.

Terraform

resource "aws_cloudwatch_log_group" "cloudtrail" {
  name              = "/aws/cloudtrail/management-events"
  retention_in_days = 365
}

resource "aws_iam_role" "cloudtrail_cw" {
  name = "CloudTrail_CloudWatchLogs_Role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "cloudtrail.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "cloudtrail_cw" {
  name = "CloudTrail_CloudWatchLogs_Policy"
  role = aws_iam_role.cloudtrail_cw.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["logs:CreateLogStream", "logs:PutLogEvents"]
      Resource = "${aws_cloudwatch_log_group.cloudtrail.arn}:*"
    }]
  })
}

resource "aws_cloudtrail" "main" {
  name                          = "my-org-trail"
  s3_bucket_name                = aws_s3_bucket.cloudtrail.id
  cloud_watch_logs_group_arn    = "${aws_cloudwatch_log_group.cloudtrail.arn}:*"
  cloud_watch_logs_role_arn     = aws_iam_role.cloudtrail_cw.arn
  is_multi_region_trail         = true
  enable_log_file_validation    = true
}

Tip: Note the :* suffix on the log group ARN in both the IAM policy resource and the trail config. CloudTrail expects the log stream wildcard appended. Forgetting it is the single most common reason this setup silently fails to deliver events.


How to prevent it from happening again

Fixing one trail is easy. Stopping the misconfiguration from creeping back in across dozens of accounts is the real work. A few layers help.

Catch it in CI/CD

Scan your Terraform plans before they merge. A policy-as-code rule rejects any aws_cloudtrail resource that lacks the CloudWatch attributes. Here is a Checkov-style example using a custom check, though Checkov also ships CKV_AWS_252 for trail-to-CloudWatch coverage:

# Run as a pre-merge gate
checkov -d . --check CKV_AWS_252

An OPA/Conftest rule does the same against the plan JSON:

package main

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_cloudtrail"
  not resource.change.after.cloud_watch_logs_group_arn
  msg := sprintf("CloudTrail '%s' must deliver to CloudWatch Logs", [resource.address])
}

Enforce at the organization level

If you run AWS Organizations, create an organization trail from the management account. It applies to every member account automatically and cannot be tampered with by individual account owners. Configure it once with CloudWatch Logs and the entire org inherits the setting.

Tip: Pair an org trail with a Service Control Policy that denies cloudtrail:StopLogging, cloudtrail:DeleteTrail, and cloudtrail:UpdateTrail for everyone except a dedicated security role. This blocks the exact anti-forensic actions an attacker would attempt.

Continuous monitoring

Run Lensix against your accounts on a schedule so a drifted or newly created trail without CloudWatch gets flagged within hours, not at your next audit. Combine the detection with the metric filters below so the moment logging is altered, you hear about it.


Best practices

Once the trail feeds CloudWatch, get value out of it. At minimum, set up metric filters and alarms for the high-signal events:

aws logs put-metric-filter \
  --log-group-name /aws/cloudtrail/management-events \
  --filter-name StopLoggingFilter \
  --filter-pattern '{ ($.eventName = "StopLogging") || ($.eventName = "DeleteTrail") }' \
  --metric-transformations \
      metricName=CloudTrailTamperingCount,metricNamespace=Security,metricValue=1

Beyond that, a healthy CloudTrail posture looks like this:

  • Use a multi-region trail. Single-region trails miss activity in other regions, which is exactly where attackers like to operate.
  • Enable log file validation. The enable_log_file_validation flag lets you prove logs were not altered after delivery.
  • Set sensible retention. Keep S3 archives long for compliance, but use shorter CloudWatch retention to control cost since CloudWatch is your hot, queryable layer.
  • Lock down the S3 bucket. Block public access, enable SSE-KMS encryption, and restrict the bucket policy to CloudTrail's service principal.
  • Alarm on the right events. Root account usage, IAM policy changes, security group changes, and disabled MFA are all worth metric filters.

The CloudWatch integration is the small piece that ties all of this together. Without it, the rest of your logging stack is a filing cabinet nobody opens. With it, your audit trail becomes a live nervous system that tells you when something is wrong while you can still do something about it.