Find who changed something, or why a call was denied, with CloudTrail
Answer "who deleted / changed this?" and "which call was denied, and for whom?" from CloudTrail — Event history and LookupEvents for the last 90 days of management events (one filter per call, 2 requests per second, one Region), a trail's log group in Logs Insights for errorCode searches, CloudTrail Lake for SQL across accounts — and how to read the identity behind an assumed role.
markdownguidev4143C-87EVstage: operatetrust: officialservice: cloudtrailinterface: cliinterface: sdkinterface: consolewho deletedwho changedCloudTrail event historylookup-eventsLookupEvents TypeScriptAttributeKey EventName ResourceName UsernameCloudTrail 90 daysCloudTrail delay 5 minutesfind AccessDenied in CloudTrailerrorCode errorMessageCloudTrail Logs InsightsCloudTrail Lakedata events S3 not in event historyassumed-role sessionsourceIdentitymulti-Region trailglobal service events us-east-1
Find who changed something, or why a call was denied
For a control-plane change in the last 90 days, ask Event history first: it is on in every account, free, and needs no setup. Search by ResourceName or EventName in the Region where the call was made. Object-level S3 reads and writes, Lambda invokes and other data events are not there. They exist only if a trail or event data store was configured to record them beforehand.
Pick the tool
| Question | Use | Limits |
|---|---|---|
| Who changed or deleted resource X (last 90 days)? | Event history / lookup-events | Management events only, one Region per call, one filter per call, 2 requests/s per account per Region, 50 events per page |
| Every denied call in the last hour, by principal | A trail delivering to CloudWatch Logs, then Logs Insights | Needs the trail set up before the event happened |
| Across accounts or beyond 90 days, with SQL | CloudTrail Lake (event data store) | Paid per data scanned; retention set by the pricing option |
| S3 object access, Lambda invocations | A trail or event data store with data events enabled | Opt-in and billed. Advanced selectors narrow it |
Events reach trails about 5 minutes after the call on average, and that isn't guaranteed. If a change from a minute ago is missing, wait.
1. Event history: who touched this resource?
aws cloudtrail lookup-events --region <region> \
--lookup-attributes AttributeKey=ResourceName,AttributeValue=<name-or-id> \
--start-time 2026-10-17T00:00:00Z --max-items 50
AttributeKeyis case-sensitive and takes one of:AccessKeyId,EventId,EventName,EventSource,ReadOnly,ResourceName,ResourceType,Username. You get one pair per call, so filter the rest yourself.AttributeKey=ReadOnly,AttributeValue=falsereturns only writes.- Results come newest first.
CloudTrailEventis the whole record as a JSON string: parse it to readuserIdentity,errorCodeandrequestParameters. - Global-service calls such as IAM are recorded in a specific Region. Repeat the lookup in
us-east-1if your Region shows nothing (UNVERIFIED: the Region rule for global services isn't on the fetched pages).
import { CloudTrailClient, paginateLookupEvents } from "@aws-sdk/client-cloudtrail";
const ct = new CloudTrailClient({ region: "us-east-1" });
/** Writes to a resource in the last `hours`, with who made them and whether they failed. */
export async function whoTouched(resourceName: string, hours = 24) {
const out: { time?: Date; event?: string; who?: string; error?: string }[] = [];
const pages = paginateLookupEvents({ client: ct, pageSize: 50 }, {
LookupAttributes: [{ AttributeKey: "ResourceName", AttributeValue: resourceName }],
StartTime: new Date(Date.now() - hours * 3600_000),
});
for await (const page of pages) {
for (const e of page.Events ?? []) {
const rec = JSON.parse(e.CloudTrailEvent ?? "{}");
if (rec.readOnly) continue; // the API allows one filter only, so filter writes here
out.push({ time: e.EventTime, event: e.EventName, who: rec.userIdentity?.arn, error: rec.errorCode });
}
}
return out;
}
console.table(await whoTouched("my-bucket"));
Event history allows only 2 requests per second. The SDK retries ThrottlingException, but for wide searches use a trail or Lake.
2. Find denied calls (trail → CloudWatch Logs → Logs Insights)
A trail can send its events to a log group, and a multi-Region trail sends every Region to that one group. Then query it:
filter ispresent(errorCode)
| stats count(*) as calls by errorCode, eventSource, eventName, userIdentity.arn
| sort calls desc
| limit 50
Narrow it to authorization failures. Services spell these differently, for example AccessDenied, AccessDeniedException or EC2's UnauthorizedOperation, sometimes with a prefix. The regex matches on substrings, so it catches the variants:
filter errorCode like /AccessDenied|UnauthorizedOperation/
| fields @timestamp, userIdentity.arn, eventSource, eventName, errorMessage
| sort @timestamp desc
| limit 100
errorMessagecarries the authorization failure text. Decode it withguides/iam/fix-access-denied.md.- Denials by a VPC endpoint policy show
errorCodeVpceAccessDeniedand the message "The request was denied due to a VPC endpoint policy." (patterns/iam-to-vpc-endpoints.md). - More queries are in
reference/cloudwatch/logs-insights-queries.md.
3. Read the identity
Most calls come from roles. In an AssumedRole record:
userIdentity.arn=arn:aws:sts::<account>:assumed-role/<RoleName>/<SessionName>. The session name is your best clue to the human or job. CI sessions named after a run ID, such asgha-<run id>fromguides/iam/ci-oidc-github.md, map straight to a workflow run.userIdentity.sessionContext.sessionIssuer.arnis the role itself.userIdentity.sessionContext.sourceIdentityis the original user, if your STS setup requires a source identity. It follows the session through role chains.invokedBynames the AWS service that made the call on your behalf. Examples are CloudFormation with a service role, or Auto Scaling.onBehalfOf.userIdis the Identity Center user forIdentityCenterUserevents.- Cross-account calls set
recipientAccountIdto the account that received the call, andsharedEventIDlinks the copies in both accounts.
To get from a session back to whoever started it, find the AssumeRole, AssumeRoleWithWebIdentity or AssumeRoleWithSAML event that created it: same session name, around attributes.creationDate. Field-by-field detail is in reference/cloudtrail/event-records.md.
Nothing found?
- Wrong Region: Event history and trails are Regional unless the trail is multi-Region.
- Data event: not recorded unless it was enabled beforehand.
- Too recent: delivery averages about 5 minutes.
- Older than 90 days: only a trail's S3 copy or Lake has it.
CloudTrail API errors (LookupEvents throttling, trail delivery, Lake queries): reference/cloudtrail/errors.md.