This check flags AWS accounts that have no CloudWatch alarm watching for unauthorized or access-denied API calls. Without it, an attacker probing your account with stolen or misconfigured credentials goes unnoticed. Fix it by creating a metric filter on your CloudTrail log group plus an alarm wired to an SNS topic.
When someone uses credentials they should not have, AWS usually says no. The API call returns an AccessDenied or UnauthorizedOperation error, the action fails, and life goes on. The problem is that nobody hears the door rattle. A failed API call is one of the earliest signals that something is wrong, whether it is a leaked access key being tested, an over-scoped automation script gone rogue, or an attacker mapping out what your credentials can reach.
CIS AWS Foundations Benchmark control 3.1 exists for exactly this reason. It asks you to alarm on unauthorized API activity so that failed attempts surface as alerts instead of dissolving into log noise. This Lensix check confirms whether that alarm actually exists in your account.
What this check detects
The account_alarm_unauthorizedapi check inspects your account for a CloudWatch metric filter and alarm combination that targets unauthorized API calls in your CloudTrail logs. Specifically, it looks for a metric filter matching CloudTrail events where the error code is AccessDenied or UnauthorizedOperation, paired with a CloudWatch alarm that fires when those events occur.
If either piece is missing, the metric filter, the alarm, or the SNS notification target, the check fails. A passing result means failed permission events generate a metric data point, and crossing your threshold triggers a notification.
Note: This check depends on having a multi-region CloudTrail trail that delivers events to a CloudWatch Logs group. If you have no trail logging to CloudWatch Logs, there is nothing for the metric filter to read, and the alarm cannot work. CIS control 3.1 assumes the logging foundation from CIS section 3 is already in place.
Why it matters
Failed API calls are cheap for an attacker and high signal for a defender. Consider how a typical credential compromise plays out:
- An access key leaks through a committed
.envfile, a public S3 bucket, or a misconfigured CI pipeline. - The attacker runs reconnaissance to learn what the credential can do. Tools like enumeration scripts call dozens of APIs, and many of those calls fail with
AccessDenied. - Each failure is recorded in CloudTrail, but without an alarm it stays buried among thousands of routine events.
- By the time anyone reviews the logs, the attacker has already found the actions they are allowed to perform and moved on to them.
That burst of denied calls is your earliest warning. Legitimate workloads rarely generate a flood of authorization failures. When they spike, something has changed: a new principal is testing boundaries, an automation role lost a permission it relied on, or a developer is poking at resources they should not touch.
There is an operational angle too. A sudden rise in AccessDenied errors often points to a broken deployment where an IAM policy change quietly took permissions away from a service. The same alarm that catches an intruder also catches the self-inflicted outage before your pager does.
Warning: Unauthorized API alarms can be noisy in busy accounts. A naive threshold of one failure will page you constantly. Tune your threshold and evaluation period to your environment so the alarm stays meaningful, otherwise it will be ignored, which is worse than not having it.
How to fix it
The fix has three moving parts: a metric filter on your CloudTrail log group, an SNS topic with at least one subscriber, and a CloudWatch alarm tying them together. The steps below assume your CloudTrail trail already ships logs to a CloudWatch Logs group named CloudTrail/DefaultLogGroup. Adjust the name to match yours.
Step 1: Create an SNS topic and subscribe to it
aws sns create-topic --name lensix-security-alerts
aws sns subscribe \
--topic-arn arn:aws:sns:us-east-1:111122223333:lensix-security-alerts \
--protocol email \
--notification-endpoint [email protected]
Confirm the subscription from the email AWS sends before moving on, or the alarm will publish to a topic nobody receives.
Step 2: Create the metric filter
This filter matches CloudTrail events with an access-denied or unauthorized error code and increments a custom metric each time one appears.
aws logs put-metric-filter \
--log-group-name CloudTrail/DefaultLogGroup \
--filter-name UnauthorizedAPICalls \
--filter-pattern '{ ($.errorCode = "*UnauthorizedOperation") || ($.errorCode = "AccessDenied*") }' \
--metric-transformations \
metricName=UnauthorizedAPICalls,metricNamespace=CISBenchmark,metricValue=1
Step 3: Create the alarm
aws cloudwatch put-metric-alarm \
--alarm-name "CIS-3.1-UnauthorizedAPICalls" \
--alarm-description "Alarm on unauthorized API calls (CIS 3.1)" \
--metric-name UnauthorizedAPICalls \
--namespace CISBenchmark \
--statistic Sum \
--period 300 \
--evaluation-periods 1 \
--threshold 1 \
--comparison-operator GreaterThanOrEqualToThreshold \
--treat-missing-data notBreaching \
--alarm-actions arn:aws:sns:us-east-1:111122223333:lensix-security-alerts
Once the alarm exists and your subscription is confirmed, the next batch of denied API calls will trigger a notification, and the Lensix check will flip to passing on the next scan.
Terraform version
If you manage infrastructure as code, define all three resources together so the alarm cannot drift away from its dependencies:
resource "aws_sns_topic" "security_alerts" {
name = "lensix-security-alerts"
}
resource "aws_cloudwatch_log_metric_filter" "unauthorized_api" {
name = "UnauthorizedAPICalls"
log_group_name = "CloudTrail/DefaultLogGroup"
pattern = "{ ($.errorCode = \"*UnauthorizedOperation\") || ($.errorCode = \"AccessDenied*\") }"
metric_transformation {
name = "UnauthorizedAPICalls"
namespace = "CISBenchmark"
value = "1"
}
}
resource "aws_cloudwatch_metric_alarm" "unauthorized_api" {
alarm_name = "CIS-3.1-UnauthorizedAPICalls"
alarm_description = "Alarm on unauthorized API calls (CIS 3.1)"
namespace = "CISBenchmark"
metric_name = "UnauthorizedAPICalls"
statistic = "Sum"
period = 300
evaluation_periods = 1
threshold = 1
comparison_operator = "GreaterThanOrEqualToThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [aws_sns_topic.security_alerts.arn]
}
Tip: CIS section 3 has more than a dozen alarm requirements (root account usage, IAM policy changes, console sign-in failures, and others). Rather than wiring each one by hand, deploy them as a single Terraform module or CloudFormation stack that reuses one SNS topic. You solve a whole class of checks in one pass instead of chasing them individually.
How to prevent it from happening again
Manual fixes drift. An account created next quarter, or a new region brought online, will not have this alarm unless something enforces it. Build the guardrails into your delivery pipeline:
- Make the alarm part of your account baseline. If you provision accounts through AWS Control Tower, Organizations, or a landing zone, bake the metric filter and alarm into the baseline stack that every account inherits. New accounts then start compliant.
- Add a policy-as-code gate. Run Checkov, tfsec, or OPA against your Terraform plans in CI. Write a rule that fails the build when a CloudTrail log group exists without a corresponding unauthorized-API metric filter and alarm.
- Scan continuously. A one-time fix tells you nothing about tomorrow. Let Lensix run the
account_alarm_unauthorizedapicheck on a schedule so you catch deleted alarms, broken SNS subscriptions, or new accounts that slipped through.
A lightweight OPA rule to block the gap in CI might look like this:
package terraform.cloudtrail
deny[msg] {
some r
input.resource_changes[r].type == "aws_cloudwatch_log_metric_filter"
not has_unauthorized_filter
msg := "No metric filter for unauthorized API calls (CIS 3.1)"
}
has_unauthorized_filter {
some r
input.resource_changes[r].type == "aws_cloudwatch_log_metric_filter"
contains(input.resource_changes[r].change.after.pattern, "AccessDenied")
}
Best practices
Getting the alarm in place is the baseline. A few habits make it genuinely useful:
- Route alerts somewhere people read. An email topic nobody checks is theater. Send alarms to a Slack or PagerDuty channel that your on-call rotation actually watches, and treat a spike as an incident worth triaging.
- Tune the threshold to reality. Measure your normal rate of denied calls for a week, then set the threshold above that baseline. The goal is to catch anomalies, not to page on every typo in a developer's CLI session.
- Pair the alarm with an investigation runbook. When it fires, responders should know to query CloudTrail for the offending principal, the source IP, and the failed actions. Decide in advance whether the response is to disable a key, revoke a session, or quarantine a role.
- Cover every region. Use a multi-region CloudTrail trail so calls in regions you rarely use are not a blind spot. Attackers often probe quiet regions precisely because monitoring tends to be thinner there.
- Do not stop at 3.1. Unauthorized API alarms work best alongside the rest of the CIS monitoring controls. Together they form a tripwire layer that surfaces the activity that matters before it becomes a breach report.
Danger: If this alarm fires repeatedly against your own account, do not silence it to stop the noise. Investigate first. Disabling a security alarm to quiet a real signal is how a contained intrusion becomes a full compromise. Tune the threshold, never the visibility.
An unauthorized API call alarm is one of the cheapest, highest-value controls you can add to an AWS account. It costs almost nothing to run, takes a few minutes to set up, and turns the failures an attacker generates during reconnaissance into the alert that stops them.

