This check flags AWS accounts that have no CloudWatch alarm watching for route table changes. Without it, an attacker or careless admin can reroute your traffic and you won't know. Fix it by creating a metric filter on your CloudTrail log group and wiring it to an SNS-backed CloudWatch alarm.
Route tables are the traffic cops of your VPC. They decide whether a subnet talks to the internet, stays private, or sends everything through an inspection appliance. A single change to a route table can silently expose an internal subnet, redirect outbound traffic to an attacker-controlled NAT, or break connectivity for an entire tier of your application.
CIS AWS Foundations Benchmark control 3.13 requires that you monitor for these changes. The Lensix check account_alarm_routechanges verifies that a CloudWatch alarm exists to do exactly that. When it fails, you have no automated signal the moment someone modifies how your network routes traffic.
What this check detects
The check inspects your account for a CloudWatch metric filter and alarm combination that matches route table API activity in CloudTrail. Specifically, it looks for monitoring tied to these EC2 events:
CreateRouteCreateRouteTableReplaceRouteReplaceRouteTableAssociationDeleteRouteTableDeleteRouteDisassociateRouteTable
If no metric filter parses these events out of your CloudTrail logs, and no alarm fires on the resulting metric, the check reports a failure. In short: the events are probably being logged, but nobody and nothing is watching them.
Note: This check depends on having a multi-region CloudTrail trail that delivers to a CloudWatch Logs group. If CloudTrail is not sending events to CloudWatch Logs, there is no log stream for a metric filter to read, so fix that prerequisite first.
Why it matters
Route table changes sit at the intersection of availability and security, which is what makes them dangerous when unmonitored.
The attack scenario
Imagine an attacker who has gained access to credentials with EC2 networking permissions. They don't need to touch your instances or your data directly. Instead they add a route in a private subnet's route table pointing 0.0.0.0/0 at an internet gateway, or they swap the route table association so traffic flows through a NAT instance they control. Now they can exfiltrate data or perform a man-in-the-middle on outbound connections, and from the outside everything looks normal. No instance was created, no security group was opened in an obvious way.
Without an alarm, this change blends into the noise of everyday infrastructure work. Your first sign of trouble might be an unexpected AWS bill or a breach disclosure months later.
The availability scenario
Route table mistakes are just as often accidental. An engineer running a Terraform apply against the wrong workspace removes a route to a transit gateway, and an entire environment loses connectivity to shared services. With an alarm in place you get notified within minutes and can correlate the outage with the exact API call and the identity behind it.
Warning: Route changes do not generate VPC Flow Log entries, and they are easy to miss in a busy CloudTrail. Relying on someone to spot them by reading logs is not a control. The alarm is what turns a buried log line into an actionable signal.
How to fix it
The remediation has three pieces: a metric filter on your CloudTrail log group, an SNS topic with a subscription, and a CloudWatch alarm that ties them together. Below is the full sequence using the AWS CLI.
1. Confirm your CloudTrail log group
Find the CloudWatch Logs group your multi-region trail delivers to:
aws cloudtrail describe-trails \
--query 'trailList[?IsMultiRegionTrail==`true`].[Name,CloudWatchLogsLogGroupArn]' \
--output table
Note the log group name from the ARN, for example aws-cloudtrail-logs.
2. Create the metric filter
This filter matches any of the route table events and increments a custom metric whenever one appears.
aws logs put-metric-filter \
--log-group-name "aws-cloudtrail-logs" \
--filter-name "RouteTableChanges" \
--filter-pattern '{ ($.eventSource = "ec2.amazonaws.com") && (($.eventName = "CreateRoute") || ($.eventName = "CreateRouteTable") || ($.eventName = "ReplaceRoute") || ($.eventName = "ReplaceRouteTableAssociation") || ($.eventName = "DeleteRouteTable") || ($.eventName = "DeleteRoute") || ($.eventName = "DisassociateRouteTable")) }' \
--metric-transformations \
metricName=RouteTableChangeCount,metricNamespace=CISBenchmark,metricValue=1,defaultValue=0
3. Create an SNS topic and subscribe to it
TOPIC_ARN=$(aws sns create-topic --name cis-security-alerts --query 'TopicArn' --output text)
aws sns subscribe \
--topic-arn "$TOPIC_ARN" \
--protocol email \
--notification-endpoint [email protected]
Confirm the subscription from the email AWS sends before moving on, otherwise the alarm has nowhere to deliver.
4. Create the CloudWatch alarm
aws cloudwatch put-metric-alarm \
--alarm-name "CIS-3.13-RouteTableChanges" \
--alarm-description "Alarm on any route table change (CIS 3.13)" \
--namespace "CISBenchmark" \
--metric-name "RouteTableChangeCount" \
--statistic Sum \
--period 300 \
--evaluation-periods 1 \
--threshold 1 \
--comparison-operator GreaterThanOrEqualToThreshold \
--treat-missing-data notBreaching \
--alarm-actions "$TOPIC_ARN"
Once this is in place, any route table change within a five minute window pushes the metric to at least 1, the alarm transitions to ALARM, and your security team gets an email.
Tip: Email is fine for a proof of concept, but route the SNS topic to your incident tooling instead. Subscribe a Lambda, a PagerDuty integration, or a Slack webhook so the alert lands where your responders already work and can be acknowledged.
Doing it with Infrastructure as Code
Creating this by hand in one account is fine, but it will drift and it will not scale across accounts. Define it as code. Here is the same control in Terraform.
resource "aws_cloudwatch_log_metric_filter" "route_table_changes" {
name = "RouteTableChanges"
log_group_name = var.cloudtrail_log_group_name
pattern = "{ ($.eventSource = \"ec2.amazonaws.com\") && (($.eventName = \"CreateRoute\") || ($.eventName = \"CreateRouteTable\") || ($.eventName = \"ReplaceRoute\") || ($.eventName = \"ReplaceRouteTableAssociation\") || ($.eventName = \"DeleteRouteTable\") || ($.eventName = \"DeleteRoute\") || ($.eventName = \"DisassociateRouteTable\")) }"
metric_transformation {
name = "RouteTableChangeCount"
namespace = "CISBenchmark"
value = "1"
default_value = "0"
}
}
resource "aws_cloudwatch_metric_alarm" "route_table_changes" {
alarm_name = "CIS-3.13-RouteTableChanges"
alarm_description = "Alarm on any route table change (CIS 3.13)"
namespace = "CISBenchmark"
metric_name = "RouteTableChangeCount"
statistic = "Sum"
period = 300
evaluation_periods = 1
threshold = 1
comparison_operator = "GreaterThanOrEqualToThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [var.security_sns_topic_arn]
}
Because CIS 3.13 is one of more than a dozen near-identical alarm controls, consider wrapping the metric filter and alarm in a small reusable module that takes the filter pattern, metric name, and alarm name as inputs. You define the pattern once and instantiate it for every CIS alarm control.
How to prevent it from coming back
A one-time fix is not a control. Treat the alarm as something that must exist in every account, forever, and enforce that.
- Deploy through a landing zone. Use AWS Control Tower or a StackSet so every new account inherits the full set of CIS alarms at vending time. New accounts should never start without them.
- Gate IaC in CI/CD. Run a policy check on your plans so a pull request that touches monitoring cannot remove these alarms without an explicit, reviewed override.
- Run continuous compliance scans. Have Lensix re-run this check on a schedule so that if someone deletes the alarm or detaches CloudTrail from CloudWatch Logs, you find out the same day rather than at the next audit.
A simple Open Policy Agent rule can keep the alarm in your Terraform plans:
package cis.alarms
deny[msg] {
required := "CIS-3.13-RouteTableChanges"
alarms := { r.values.alarm_name |
r := input.resource_changes[_]
r.type == "aws_cloudwatch_metric_alarm"
r.change.after != null
}
not alarms[required]
msg := sprintf("Required CIS alarm %q is missing from the plan", [required])
}
Danger: Do not delete or rename the CloudTrail log group or detach it from your trail to "clean things up." Every metric filter reads from that log group. Removing it silently disables this alarm and every other CIS alarm at once, leaving you blind with no error and no warning.
Best practices
- Centralize CloudTrail and the alarms. Aggregate organization trails into a dedicated logging or security account and run the metric filters there, so a compromised member account cannot tamper with its own monitoring.
- Tune the alarm action, not the threshold. Route table changes are infrequent, so a threshold of 1 is correct. Resist the urge to raise it to cut noise. If you get too many alerts, the right fix is to reduce who can change route tables, not to mute the alarm.
- Enrich the alert. Have the SNS subscriber pull the originating
userIdentityandsourceIPAddressfrom the CloudTrail event so responders see who made the change and from where, without digging through logs. - Cover the whole CIS alarm family. Route table changes are control 3.13, but the same pattern applies to network gateway changes, NACL changes, and security group changes. Deploy them as a set so your network monitoring has no gaps.
- Test it. Once a quarter, make a harmless route change in a sandbox VPC and confirm the alarm fires and reaches a human. An untested alarm is an assumption, not a control.
Route table monitoring is cheap to set up and costs almost nothing to run, yet it closes a real blind spot between your networking layer and your security operations. Get it deployed everywhere, enforce it as code, and verify it keeps working.

