A worked incident: trace a bulk S3 download back to a session using one account's logs, then stop it without taking down the services that share the role.
An alert says a large number of objects were downloaded from a sensitive S3 bucket. The activity used a role assumed from another AWS account, and the temporary credentials are still valid. This post covers two questions an incident commander will ask: what happened, and can we stop it right now without causing an outage? The answers depend on knowing exactly what AWS logs prove and what IAM changes actually affect.
AppRole) that is allowed to assume a role in Account B.ProductionDataReader, can read a sensitive S3 bucket. Five other production services also use it.Keep these three apart in everything you write down. Mixing them up is how incident reports turn into guesses.
| Level | Examples here |
|---|---|
| Proves | A specific STS session made specific API calls, at specific times, from a specific source IP and user agent. |
| Suggests | Where the activity came from (a network, a tool such as the AWS CLI or a Python SDK), and which workload owned the credentials. |
| Unknown | Which process or person used the credentials, whether they were stolen or misused by an insider, and what else the attacker did outside the logs you can see. |
Start from what you know for sure (the suspicious S3 activity) and work backward to the identity. Do not start by searching for AssumeRole and hoping.
CloudTrail records S3 object-level actions such as GetObject only if data events are enabled for the bucket. They are off by default and billed separately, and you select each resource type on its own (general purpose buckets and S3 directory buckets use different types). Without them you can see who assumed the role, but not which objects were read. Alternatives are S3 server access logs (if enabled) and GuardDuty S3 findings. Find out in the first minutes what you have, because it decides how well you can measure the damage.
Look at the GetObject events for the bucket in the alert window and pull out the session identity:
| Field | What it tells you |
|---|---|
userIdentity.type, arn | An assumed-role session: arn:aws:sts::<account>:assumed-role/ProductionDataReader/<session-name> |
userIdentity.accessKeyId | The temporary access key (starts with ASIA). This is the key you will follow. AWS notes it can be missing or empty in some events, so also record the fields below. |
userIdentity.principalId, sessionContext | The session ID (AROA...:session-name), the role that was assumed, when the credentials were issued (creationDate), and sourceIdentity if one was set |
sourceIPAddress, userAgent | Where the request came from and what tool made it |
requestParameters (bucket, key) | Which objects were read |
eventTime | When it started, how fast, and whether it is still going |
-- Athena, table partitioned by region, year, month, day (adjust names to your table)
SELECT eventtime, sourceipaddress, useragent,
useridentity.accesskeyid,
json_extract_scalar(requestparameters, '$.key') AS object_key
FROM cloudtrail_logs
WHERE eventsource = 's3.amazonaws.com'
AND eventname = 'GetObject'
AND useridentity.sessioncontext.sessionissuer.arn LIKE '%/ProductionDataReader'
AND region = 'us-east-1' AND year = '2026' AND month = '11' AND day = '04'
AND eventtime > '2026-11-04T17:00:00Z'
ORDER BY eventtime;
The table that the CloudTrail console creates for you is not partitioned, so every query scans all the log files. For incident work, use a partitioned table (region, year, month and day, with the partitions loaded) or partition projection, and always filter on those columns. CloudTrail Lake is another option. Otherwise queries are slow and costly when you need answers fast.
Group the results by accessKeyId and sourceIPAddress. Five legitimate services and one attacker will usually form separate groups. The suspicious one is the key with the unusual IP, user agent, volume or object pattern.
Now find the STS call that created that key. In Account B, the AssumeRole event includes:
| Field | Use |
|---|---|
responseElements.credentials.accessKeyId | The link. Match it to the accessKeyId in the S3 events. This is exact, unlike matching by time or IP. |
responseElements.assumedRoleUser | The assumed-role ARN and ID (AROA...:session-name). That ID is the value aws:userid takes for this session. |
userIdentity (the caller) | In the target account this entry is minimal: type AWSAccount, the calling account ID and a principal ID. The full caller ARN and access key are recorded in the calling account's own log. |
requestParameters.roleArn, roleSessionName | Which role, and the session name the caller chose |
sourceIdentity (if used) | A name that stays attached to the session, set at assume time |
sourceIPAddress, userAgent | Network origin and tool for the assume call |
responseElements.credentials.expiration | When the credentials die. Tells you how long you have. |
If the key ID is missing from the S3 events, link on the session ID (principalId) and match creationDate to the AssumeRole event time. Do not link events by IP address or timestamp alone. Several sessions can share a NAT gateway, and many sessions can start in the same minute. The access key ID is the key.
Two things to know about where the STS events are. Calls to the global STS endpoint (sts.amazonaws.com) are logged in the us-east-1 region, while calls to a regional STS endpoint are logged in that region. If you do not see the AssumeRole event where you expect it, check us-east-1 and use a multi-region trail or CloudTrail Lake. Also, CloudTrail does not log denied cross-account AssumeRole requests in the target account, so failed attempts against your role will not appear in Account B.
In Account B, the caller appears as the source account (Account A) and a principal ID. For a role session, the principal ID normally ends with the session name, and for EC2 instance profile credentials the session name is commonly the instance ID (I did not find this stated in AWS's documentation, so confirm it in your own logs). So it may point to the instance whose credentials were used. Check the real value in your logs, because the full caller ARN is only in Account A's own CloudTrail. Treat it as a strong lead, not a conclusion.
It is still not proof of who acted. Credentials can be copied off an instance and used from anywhere. Compare the sourceIPAddress in the assume call with the instance's known public or NAT address (from Account A):
An IP address is a network location, not an identity. It can belong to a NAT gateway, proxy, VPN or other shared infrastructure.
Search all events for that access key ID, in all services and regions, not only S3:
ListBucket calls, and other services the role can reach.AssumeRole calls (role chaining into other accounts).Write down the object list, count and bytes read. Legal and privacy teams will need the exact scope, and it is only as good as the logging you confirmed in Step 0.
Partly. Account B can tell you the calling account, a principal ID that may include the session name (often the instance ID), the source IP and the tool. It cannot tell you what ran on the instance, who the attacker is, or how they got the credentials. For that you need Account A: its CloudTrail, GuardDuty findings, VPC flow logs, EC2 inventory and logs from the host itself.
The credentials still have 45 minutes left and five legitimate services use the same role. First, the facts that decide what works.
The trust policy controls who can assume the role. Tightening it blocks new assumptions only. Credentials already issued keep working until they expire. That is 1 hour by default, up to 12 hours if the role's maximum session duration allows, and role chaining caps a session at 1 hour. In this scenario an instance role is calling AssumeRole, which is role chaining, so the session in Account B lasts at most 1 hour. Tightening the trust policy is worth doing to stop the attacker getting fresh credentials, but it does nothing about the session they already hold.
| Mechanism | What it does | Trade-off |
|---|---|---|
| Change the trust policy | Stops new role assumptions | Existing credentials keep working |
| Targeted explicit deny | Blocks one session using an identity condition | Needs an attribute that uniquely identifies the bad session |
Deny by aws:TokenIssueTime | Blocks every session issued before a cutoff time | Also blocks legitimate sessions issued before the cutoff until they refresh |
| S3 bucket policy explicit deny | Protects the data immediately, whatever the role | Can block real applications if too broad. Keep an admin exception. |
| Network conditions (IP, VPC endpoint) | Limits requests to approved network paths | Fails if the attacker shares an approved path |
| Delete the role | Removes the access | Causes the outage you were trying to avoid, and loses configuration you need |
If the bad session can be told apart from the good ones, deny just that. For an assumed-role session, aws:userid has the form <role-id>:<session-name>, so a unique session name works. aws:SourceIdentity and session tags can work too, if you set them up beforehand.
{
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"StringLike": { "aws:userid": "AROAEXAMPLEROLEID:suspicious-session-name" }
}
}
This only works if the attribute is genuinely unique. The caller chooses the session name, and it can change when roles are chained. If legitimate services use the same name (for example a default like app), a deny on it would cut them off as well. Check the real values in CloudTrail before relying on this.
Tip: the exact value to deny is already in your evidence. Copy userIdentity.principalId from the suspicious S3 event. It has the form AROA...:session-name, where AROA... is the role's unique ID, not its name. It also appears as assumedRoleUser.assumedRoleId in the AssumeRole event. You can look up the role ID with aws iam get-role --role-name ProductionDataReader --query Role.RoleId.
This does not stop the attacker from getting a new session. The caller chooses the session name (2 to 64 characters). If they still hold valid credentials for the calling role, they can assume the role again under a different name, and this deny will not match. Pair it with a change to the role's trust policy that blocks the compromised caller, and with isolating the host (section 5).
If you cannot isolate the session, deny all sessions issued before a chosen time. The condition key aws:TokenIssueTime records when the temporary credentials were created.
{
"Effect": "Deny",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::sensitive-data/*",
"Condition": {
"DateLessThan": { "aws:TokenIssueTime": "2026-11-04T18:00:00Z" }
}
}
Walk through three sessions with a cutoff at 18:00 UTC:
| Session | Issued | Result |
|---|---|---|
| Attacker | 17:30 | Denied. Issued before the cutoff. |
| Service A | 17:45 | Denied. Issued before the cutoff, even though it is legitimate. |
| Service B | 18:15 | Not affected by this statement. It still needs permission from the rest of the policies. |
Three limits you must state out loud:
s3:GetObject on one bucket. The attacker's credentials can still call DynamoDB, other buckets or other APIs the role allows. During an active incident, widen it to "Action": "*" for old sessions, or review what else the role can do. The IAM console's "Revoke active sessions" button does the wide version: it attaches an inline deny-all policy named AWSRevokeOlderSessions to the role, with a cutoff about 30 seconds in the future to allow for propagation, and it applies to every user of the role.aws:TokenIssueTime is absent from requests signed with long-term access keys. It will not stop an IAM user key the attacker may have created.Use an explicit deny, not just a removal of permissions. If a bucket policy also allows the role, removing the role's own permissions does not stop access. An explicit deny wins over any allow.
~/.aws/cli/cache).AssumeRole events appeared with creation times after the cutoff, and check application health.AccessDenied in CloudTrail and the services are still healthy.If data is leaving right now, containment comes first. Apply the deny, or restrict the bucket, immediately, and accept a short, planned disruption. Agree this with the incident commander and the service owners. A disruption-free rollout is not worth more confidential data being downloaded.
An explicit deny in the bucket policy protects the data at once and does not depend on the role. For example, deny GetObject except for approved roles and expected network paths, and keep an exception for incident responders so you do not lock yourself out. It buys time while you work out the precise session deny.
They can help as an extra layer, but do not rely on them:
aws:SourceVpce or aws:SourceVpc for those. aws:SourceIp does not apply to requests that come through a VPC endpoint.Then no precise deny exists. Stop the exfiltration first (bucket-level deny or the cutoff deny), accept a controlled disruption, and rebuild the legitimate services' access in the right order afterwards.
You cannot see Account A, but you can ask its owners to act:
ec2RoleDelivery field on the instance's calls is 1.0 when the credentials came from IMDSv1, which is consistent with metadata theft. Fix the cause.UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWS (instance credentials used from an IP outside AWS) and .InsideAWS (used from another AWS account). GuardDuty can stop raising these for continued activity from the same remote source, so a quiet GuardDuty does not mean the activity stopped.ProductionDataReader is what made this containment hard.sts:SourceIdentity on cross-account role assumption, so sessions carry a name that cannot be changed later. Use sts:RoleSessionName to require a meaningful session name too.aws:PrincipalOrgID).ec2:RoleDelivery condition key can enforce it in policies, and CloudTrail's ec2RoleDelivery field shows whether a session's credentials came from IMDSv1 or IMDSv2.AssumeRole from unusual IPs or tools, and on unusual download volume.Use this order: decision, mechanism, verification, impact. For example:
"I would deny the compromised session in Account B now. I identified it from the S3 events by its access key and linked it to the AssumeRole call. Its session name is shared with a legitimate service, so I can't deny it individually. Instead I'll deny all sessions issued before 18:00, which also blocks the five services until they refresh. They need to restart or refresh their credentials, which the owners can do in about ten minutes. Because data is still leaving, I recommend applying the deny first and accepting that short disruption. Afterwards I'll confirm the attacker's key gets AccessDenied and that the services are healthy. In parallel, Account A's owners should isolate the instance and preserve its disk."
| What you hear | Why it is not enough |
|---|---|
| "Update the trust policy to stop them." | It only blocks new assumptions. Existing sessions keep working. |
| "Rotate the role." | A role has no password to rotate. You can cut off existing sessions or force new ones. |
| "Block the attacker's IP." | They can change network. The IP may also be shared with legitimate traffic. |
| "That IP is our EC2 instance, so the instance did it." | An IP is a location. Credentials can be used from elsewhere, and addresses are often shared. |
| "Delete the role." | It causes the outage and removes evidence of how it was configured. |
| "Our alert will tell us if it happens again." | Detection does not undo a download that already happened. |
aws:TokenIssueTime, and know its scope and collateral damage.