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

Created 18 Oct 2026 · Modified 23 Oct 2026 · 4 versions

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

QuestionUseLimits
Who changed or deleted resource X (last 90 days)?Event history / lookup-eventsManagement 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 principalA trail delivering to CloudWatch Logs, then Logs InsightsNeeds the trail set up before the event happened
Across accounts or beyond 90 days, with SQLCloudTrail Lake (event data store)Paid per data scanned; retention set by the pricing option
S3 object access, Lambda invocationsA trail or event data store with data events enabledOpt-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
  • AttributeKey is 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=false returns only writes.
  • Results come newest first. CloudTrailEvent is the whole record as a JSON string: parse it to read userIdentity, errorCode and requestParameters.
  • Global-service calls such as IAM are recorded in a specific Region. Repeat the lookup in us-east-1 if 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
  • errorMessage carries the authorization failure text. Decode it with guides/iam/fix-access-denied.md.
  • Denials by a VPC endpoint policy show errorCode VpceAccessDenied and 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 as gha-<run id> from guides/iam/ci-oidc-github.md, map straight to a workflow run.
  • userIdentity.sessionContext.sessionIssuer.arn is the role itself.
  • userIdentity.sessionContext.sourceIdentity is the original user, if your STS setup requires a source identity. It follows the session through role chains.
  • invokedBy names the AWS service that made the call on your behalf. Examples are CloudFormation with a service role, or Auto Scaling.
  • onBehalfOf.userId is the Identity Center user for IdentityCenterUser events.
  • Cross-account calls set recipientAccountId to the account that received the call, and sharedEventID links 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.

Read this document as markdown — the same bytes an agent receives.