Exam Room · Security

Lab: Alarm on Root-Account Console Login

January 26, 2028 · 12 min read

Cloud Security · part of The Exam Room

This is a lab in the Security Specialty hands-on track. The reference posts argue the decisions; this stands one up. Domain 2 asks you to detect and respond to security events, and few events deserve a faster response than the account root signing in to the console. Root can do anything, and nothing in IAM can stop it, so it should be locked away behind hardware MFA and used almost never. When it is used, security wants to know within seconds. Here we build the log group, the metric, the alarm, and the SNS topic, and you wire the part that carries the decision, since that is where the thinking lives. The full lab is in lab-scs-03-root-login-alarm.zip.

Before your first lab, do the one-time, once-per-account setup: run the zip’s preflight.sh to confirm your account is ready, then deploy the lab reaper, a standing backstop that auto-deletes any lab you forget to tear down after 24 hours. Every lab tags its stack for the reaper on deploy.

The scenario

Someone signs in to the AWS console as the account root. Not an IAM user, not an assumed role, but the root identity itself. CloudTrail does its job: it records the ConsoleLogin, and the record lands in a log group with userIdentity.type set to Root. The evidence is written down within seconds of the login. And then nothing happens, because nothing is watching the log group for that shape of event. The login is real, the log line is real, and no one is paged.

That gap is the whole problem. Having the data is not the same as detecting the event. The answer is the CIS AWS Foundations Benchmark’s root-usage monitor, a pairing you will see again and again across its monitoring recommendations: a metric filter that recognises the interesting event in the trail, a custom metric that counts it, and an alarm that fires on the count. For root usage there is no burst threshold to clear, because a single root console login is already worth waking someone. One matching event pages.

What you’re given

CloudFormation builds a log group with short retention standing in for the CloudTrail log group, a metric filter that should count root sign-ins into a custom metric (RootAccountUsageCount in the AcmeSecurity namespace), the RootAccountUsageAlarm that watches that metric with a threshold of one, and the SNS topic it pages. What is missing is one thing: the filter pattern that decides which log events count as a root console login. As shipped, the filter carries a placeholder that is valid syntax (so the stack deploys) but selects on a userIdentity.type value no CloudTrail record contains, so the metric never moves and the alarm never fires. Complete, the filter looks like this:

RootUsageMetricFilter:
  Type: AWS::Logs::MetricFilter
  Properties:
    LogGroupName: !Ref TrailLogGroup
    FilterPattern: '{ ($.userIdentity.type = "Root") && ($.userIdentity.invokedBy NOT EXISTS) && ($.eventType != "AwsServiceEvent") }'
    MetricTransformations:
      - MetricNamespace: AcmeSecurity
        MetricName: RootAccountUsageCount
        MetricValue: '1'
        DefaultValue: 0

Deploying the stack as shipped succeeds, but it behaves wrong. The placeholder pattern means RootAccountUsageCount never leaves zero, so a root login sails straight past the alarm.

Your task

Close the gap in src/template.yaml by writing the filter pattern. A CloudWatch Logs metric filter can select on structured JSON, which is exactly what a CloudTrail record is. The selector is wrapped in { }, addresses fields by path, and joins its terms with &&. Three terms pin down a root console login. First, $.userIdentity.type = "Root" says the actor is the account root itself, not an IAM user or a role. Second, $.userIdentity.invokedBy NOT EXISTS says a human signed in at the console, rather than an AWS service acting on root’s behalf, which is the case that adds the invokedBy field. Third, $.eventType != "AwsServiceEvent" excludes events AWS itself raises. Put together they are the CIS pattern for root usage.

Leave the metric transformations alone. The DefaultValue: 0 is doing quiet work, keeping the metric reporting zero through the calm periods that are the normal state, so the alarm settles to OK instead of stalling in INSUFFICIENT_DATA waiting for its first data point.

Run it

./scripts/deploy.sh          # deploys src/template.yaml
./scripts/test.sh            # writes CloudTrail-shaped events and watches the alarm
./scripts/teardown.sh        # deletes everything

test.sh never launches compute, which keeps it cheap. It writes CloudTrail-shaped log events with put-log-events and polls describe-alarms. First it writes an ordinary IAM-user ConsoleLogin and confirms the alarm ignores it, because an everyday login is not a page; then it writes a root ConsoleLogin, shaped like a real CloudTrail record with userIdentity.type of Root, no invokedBy, and an eventType of AwsConsoleSignIn, and confirms the alarm fires. Metric filters and alarms take a minute or three to react, so the “still waiting” lines are normal. With the pattern in place it prints:

  t+90s  alarm=ALARM
PASS: the ordinary login was ignored and the root console login paged.

If it fails

  • The alarm fired on the ordinary IAM-user login. The pattern is matching too broadly. It should select $.userIdentity.type = "Root", not any ConsoleLogin.
  • The alarm never fired, even after the root login. The FilterPattern is not matching the root sign-in, so RootAccountUsageCount never moves. Check that the pattern is wrapped in { }, that each field path starts with $., and that NOT EXISTS sits on $.userIdentity.invokedBy.
  • The alarm sits in INSUFFICIENT_DATA. The metric filter is producing no data. Confirm the metric transformation keeps its DefaultValue: 0.

Reveal the solution

SRC=solution ./scripts/deploy.sh && ./scripts/test.sh

What you just learned

  • CloudWatch Logs metric filters read structured JSON, not just plain text. A pattern wrapped in { } addresses fields by path, compares with = and !=, tests presence with NOT EXISTS, and joins terms with &&. That syntax is what turns a CloudTrail log group into an alarm source.
  • This is one alarm from the CIS AWS Foundations Benchmark’s monitoring set, which pairs a metric filter with an alarm for events like root usage, console logins without MFA, IAM policy changes, and unauthorised API calls. The shape is always the same: filter the trail, count the metric, alarm on the count.
  • Root usage gets a threshold of one. A noisy application metric needs a burst threshold so a single blip does not page, but a root console login is a red flag on its own, because root should be locked behind hardware MFA and used almost never.
  • A metric filter with DefaultValue: 0 keeps its metric reporting through quiet periods, so the alarm resolves to OK instead of stalling in INSUFFICIENT_DATA waiting for a first data point.

Next

The rest of the hands-on lab tracks are listed in the labs README.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.