AWS CloudTrail
The audit log for everything. Management vs data events, why the 90-day history is not enough, log file validation and querying trails with Athena.
CloudTrail records API activity in your account: who called what, when, from which IP, with which credentials, and whether it succeeded. It is the answer to "who deleted that?" and the evidence auditors ask for.
It is on by default, and the default is weaker than most people assume.
Event history is not a trail
Every account has Event history: the last 90 days of management events, queryable in the console and CLI, at no cost and with nothing to configure.
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=DeleteBucket \
--start-time 2026-09-01 \
--query 'Events[].[EventTime,Username,CloudTrailEvent]' --output text
Three things it does not give you, all of which matter:
- Only 90 days. An incident discovered in month four has no record.
- No data events. Object-level S3 access and DynamoDB item activity are absent entirely.
- Nothing durable. You cannot archive it, validate it, or query it at scale.
A trail delivers events to S3 and fixes all three. Creating one is the first thing to do in a new account.
aws cloudtrail create-trail --name org-trail \
--s3-bucket-name my-cloudtrail-logs \
--is-multi-region-trail \
--enable-log-file-validation \
--kms-key-id alias/cloudtrail
aws cloudtrail start-logging --name org-trail
start-logging is a separate call. A trail created without it exists, appears configured, and records nothing. A quietly bad outcome for something whose entire purpose is evidence.
--is-multi-region-trail matters too: a single-region trail misses activity elsewhere, and an attacker operating in an unused region leaves no trace.
Management and data events
| Management events | Data events | |
|---|---|---|
| Records | Control plane (create, delete, modify) | Data plane (object and item access) |
| Examples | RunInstances, CreateBucket, AssumeRole | GetObject, PutItem, Invoke |
| Volume | Modest | Very high |
| Cost | First copy free | Charged per event |
| Default | On | Off |
Data events are off because the volume is enormous. A busy S3 bucket generates orders of magnitude more GetObject calls than your account generates management events, and billing is per event.
Enable them selectively rather than globally:
aws cloudtrail put-event-selectors --trail-name org-trail \
--advanced-event-selectors '[
{
"Name": "Sensitive bucket writes only",
"FieldSelectors": [
{"Field": "eventCategory", "Equals": ["Data"]},
{"Field": "resources.type", "Equals": ["AWS::S3::Object"]},
{"Field": "resources.ARN", "StartsWith": ["arn:aws:s3:::customer-pii/"]},
{"Field": "readOnly", "Equals": ["false"]}
]
}
]'
That records writes to one sensitive bucket and ignores everything else. Enabling data events for all S3 buckets in a data-heavy account is a genuinely expensive mistake, and it is one checkbox away.
Reading an event
{
"eventTime": "2026-09-16T09:14:22Z",
"eventSource": "s3.amazonaws.com",
"eventName": "DeleteBucket",
"awsRegion": "eu-west-1",
"sourceIPAddress": "203.0.113.42",
"userIdentity": {
"type": "AssumedRole",
"arn": "arn:aws:sts::123456789012:assumed-role/Deployer/ci-run-8123",
"sessionContext": {
"sessionIssuer": { "userName": "Deployer" },
"attributes": { "mfaAuthenticated": "false" }
}
},
"requestParameters": { "bucketName": "old-artifacts" },
"errorCode": null
}
The fields that answer real questions:
userIdentity.type: Root is worth alarming on unconditionally. AssumedRole means a role, and sessionContext.sessionIssuer.userName tells you which one.
sourceIPAddress: an AWS service appears as a service name rather than an address.
errorCode: present on denied calls, which is how you spot enumeration attempts.
mfaAuthenticated: whether MFA was used, frequently a compliance requirement.
The session name (ci-run-8123 above) is chosen by whoever assumed the role, so setting it meaningfully in your CI pipeline pays off later.
Querying at scale
lookup-events is fine for a single question and useless across months of data. Two better options.
Athena over the S3 bucket is the established approach:
SELECT eventtime, useridentity.arn, eventname, sourceipaddress, errorcode
FROM cloudtrail_logs
WHERE eventname IN ('DeleteBucket','PutBucketPolicy','DeleteDBInstance')
AND from_iso8601_timestamp(eventtime) > current_timestamp - interval '7' day
ORDER BY eventtime DESC;
Partition the table by region and date, or every query scans the entire history and the Athena bill becomes its own problem.
CloudTrail Lake is the managed alternative. A queryable event store with retention up to ten years and no S3 or Athena setup:
aws cloudtrail start-query --query-statement \
"SELECT eventName, count(*) FROM <event-data-store-id> WHERE eventTime > '2026-09-01' GROUP BY eventName"
It costs more per ingested event than S3 storage but removes the plumbing. For a small team wanting queryable audit history without maintaining a data pipeline, it is usually the better trade.
Alerting on what matters
A trail nobody reads is compliance decoration. Send events to CloudWatch Logs and alarm on the few that are always worth waking up for:
aws cloudtrail update-trail --name org-trail \
--cloud-watch-logs-log-group-arn "$LOG_GROUP_ARN" \
--cloud-watch-logs-role-arn "$ROLE_ARN"
aws logs put-metric-filter \
--log-group-name /aws/cloudtrail/org-trail \
--filter-name root-usage \
--filter-pattern '{ $.userIdentity.type = "Root" && $.eventType != "AwsServiceEvent" }' \
--metric-transformations metricName=RootAccountUsage,metricNamespace=Security,metricValue=1
Worth alarming on: root account usage, ConsoleLogin without MFA, changes to CloudTrail itself (StopLogging, DeleteTrail), IAM policy changes, security group changes opening 0.0.0.0/0, and KMS key deletion scheduling.
StopLogging deserves special attention. Disabling the audit log is a standard early step in an intrusion, and it is the one alarm that must not depend on the trail still working.
Integrity and protection
aws cloudtrail validate-logs --trail-arn "$TRAIL_ARN" \
--start-time 2026-09-01T00:00:00Z
Log file validation writes signed digest files hourly, so you can prove logs have not been altered or deleted. It costs nothing and is the difference between logs and evidence.
Protect the bucket properly:
- A separate account, ideally, so an account compromise does not reach its own audit trail.
- Object Lock in compliance mode, which makes deletion impossible even for root.
- A bucket policy denying deletes, plus versioning and MFA delete.
- SSE-KMS encryption, with the key policy permitting CloudTrail.
Logs an attacker can delete are not an audit trail.
Organisation trails
One trail covering every account in an organisation, created from the management account:
aws cloudtrail create-trail --name org-trail \
--s3-bucket-name central-cloudtrail-logs \
--is-organization-trail --is-multi-region-trail \
--enable-log-file-validation
Member accounts cannot disable or delete it, which is precisely the point. It removes the possibility of an account operator turning off their own auditing. New accounts are covered automatically as they join. This is the correct baseline for any multi-account setup.
Common errors
InsufficientS3BucketPolicyException: Incorrect S3 bucket policy is detected
for bucket: my-cloudtrail-logs
The bucket policy must allow cloudtrail.amazonaws.com to call s3:PutObject with the expected s3:x-amz-acl condition, and s3:GetBucketAcl.
TrailNotFoundException
Trails are regional objects even when multi-region. Check the region.
A trail exists and the bucket is empty. start-logging was never called, or the KMS key policy does not allow CloudTrail.
Events appear ~15 minutes late. Normal. CloudTrail delivery is not real time, which is why critical alarms go through CloudWatch Logs rather than polling S3.
Quick reference
| Task | Command |
|---|---|
| Quick lookup (90 days) | aws cloudtrail lookup-events --lookup-attributes … |
| Create a trail | aws cloudtrail create-trail --is-multi-region-trail |
| Start it | aws cloudtrail start-logging --name <t> |
| Check status | aws cloudtrail get-trail-status --name <t> |
| Selective data events | aws cloudtrail put-event-selectors --advanced-event-selectors … |
| Verify integrity | aws cloudtrail validate-logs --trail-arn <arn> |
| Organisation-wide | --is-organization-trail |
| Query Lake | aws cloudtrail start-query --query-statement '…' |
Key takeaways
- Event history is 90 days, management events only, and not durable. Create a trail.
start-loggingis a separate call. A trail without it records nothing.- Make it multi-region, or activity in unused regions is invisible.
- Data events are off by default and billed per event. Enable them selectively.
- Alarm on
StopLogging, root usage and console login without MFA. - Enable log file validation. It is free and turns logs into evidence.
- Store logs in a separate account with Object Lock, or they are deletable by an attacker.
- Use an organisation trail that member accounts cannot disable.
- Query with Athena or CloudTrail Lake, not
lookup-events.
Check your understanding
A trail that logged nothing
A trail was created four months ago with a valid S3 bucket and correct policy. During an investigation the bucket is found to be empty, and get-trail-status shows IsLogging false. What happened?
An unexpectedly large CloudTrail bill
After enabling data events for all S3 buckets, CloudTrail costs rise sharply. The account stores millions of objects read continuously by an application. What is the right adjustment?
Frequently Asked Questions
What is the difference between CloudTrail Event history and a trail?
Event history is enabled automatically and holds the last 90 days of management events, queryable in the console and via lookup-events at no cost. A trail delivers events to an S3 bucket you control, with no retention limit, optional data events, log file validation and the ability to query at scale. Anything beyond casual investigation needs a trail. Event history cannot answer a question about an incident discovered four months later, and it never contained data events at all.
What is the difference between management events and data events?
Management events record control-plane operations: creating an instance, changing a bucket policy, assuming a role. Data events record data-plane access: reading an S3 object, writing a DynamoDB item, invoking a Lambda. Management events are on by default and the first copy is free. Data events are off by default and billed per event, because their volume is orders of magnitude higher. A read-heavy account can generate millions a day. Enable them selectively with advanced event selectors scoped to sensitive resources.
Why is my CloudTrail bucket empty?
Most often because start-logging was never called. Creating a trail does not start it, and everything else looks correctly configured. Check get-trail-status, which reports IsLogging and the last delivery time. The other causes are an S3 bucket policy that does not allow cloudtrail.amazonaws.com to write, or a KMS key policy that does not permit CloudTrail to encrypt. Also note delivery takes up to about 15 minutes, so a brand new trail legitimately looks empty at first.
How do I query CloudTrail logs efficiently?
Not with lookup-events, which is fine for one question and impractical across months. Either create an Athena table over the S3 bucket. Partitioned by region and date, or every query scans your whole history, or use CloudTrail Lake, a managed event store with SQL querying and retention up to ten years. Lake costs more per ingested event but removes the pipeline entirely, which usually makes it the better choice for a team that would otherwise not build one.
What CloudTrail events should I alarm on?
A short list covers most real value: any root account usage, ConsoleLogin without MFA, StopLogging and DeleteTrail, IAM policy and user changes, security group rules opening 0.0.0.0/0, and ScheduleKeyDeletion on KMS keys. StopLogging matters most. Disabling the audit trail is a standard early step in an intrusion. Deliver the trail to CloudWatch Logs and use metric filters, since polling S3 adds delay to exactly the alerts that need to be prompt.
How do I stop someone deleting the CloudTrail logs?
Put them in a separate AWS account, so compromising the audited account does not reach its own evidence. Enable S3 Object Lock in compliance mode, which makes objects undeletable even by root for the retention period. Add versioning, MFA delete, and a bucket policy denying delete actions. Turn on log file validation so any tampering is detectable through the signed hourly digests. An organisation trail created from the management account is the final piece, since member accounts cannot disable it.