SSO logins, human role assumption, and cross-account automation all look identical to a naive AssumedRole rule. The instance-ID session name is the one marker that isolates a stolen instance credential from the rest.
Most teams writing their first cloud credential-theft detection ship the same rule: alert when an AssumedRole identity calls AWS from an IP outside AWS. It is the obvious rule, it is in half the blog posts on the subject, and in any organization with federation it will bury you.
The reason is that AssumedRole is not rare. Every IAM Identity Center login is an assumed role. Every engineer who switches into an admin role is an assumed role. Every cross-account CI pipeline is an assumed role. All of them legitimately call AWS from outside AWS address space, because the human or the system driving them sits on the internet. Alert on the category and you are alerting on your own workforce.
The detection that works keys on the one thing that separates a stolen instance credential from all of that. This post shows what that marker is, the query that uses it, and a read it gives you for free on which of your instances are still exposed.
The credential we are hunting comes from the EC2 instance metadata service. An SSRF flaw in an internet-facing app, pointed at 169.254.169.254, reads the IAM role credentials attached to the instance, which is the 2019 Capital One pattern. From there the attacker holds keys that call AWS as that role. The theft happens on the instance; the abuse happens from wherever the attacker is sitting, and that off-box use is what we want to catch.
The rule everyone ships
-- The common first attempt: any assumed-role identity calling from a non-AWS IP.
-- In a federated tenant this returns your entire SSO and automation footprint.
SELECT useridentity.arn, sourceipaddress, eventname, eventtime
FROM cloudtrail_logs
WHERE useridentity.type = 'AssumedRole'
AND sourceipaddress NOT LIKE '%.amazonaws.com'
AND sourceipaddress NOT LIKE '10.%'
ORDER BY eventtime DESC;Run that in a tenant with SSO and cross-account pipelines and you get a wall of legitimate activity. There is no threshold that fixes it, because the volume is not anomalous, it is your business. Suppress enough of it to quiet the rule and you have suppressed the attacker along with it. The category is the wrong thing to match on.
What actually marks an instance credential
When EC2 hands a role to an instance through IMDS, it sets the role session name to the instance ID. The assumed-role ARN then looks like arn:aws:sts::, and the principal ID looks like AROA...:i-0a3f9c21d4e5b6a7. Two things follow from that.
First, nothing except IMDS credential delivery produces an i-... session name, so it is a clean marker for instance-profile credentials specifically. Second, there is no separate AssumeRole event to correlate, because the instance never calls sts:AssumeRole. The metadata service vends the credentials silently, which is exactly why analysts who go looking for an assume-role event to anchor on find nothing.
Match on that marker and the rule changes character completely.
-- Hypothesis: an EC2 instance-profile credential, stolen from IMDS, is being
-- used off-box. Instance-profile credentials are the only ones whose role
-- session name is the instance ID, so the assumed-role ARN ends in /i-...
-- That match separates them from SSO, human role assumption, and cross-account
-- automation, all of which also appear as AssumedRole from external IPs.
SELECT regexp_extract(useridentity.arn, 'assumed-role/([^/]+)/', 1) AS role,
regexp_extract(useridentity.arn, '/(i-[0-9a-f]+)$', 1) AS instance_id,
sourceipaddress AS source_ip,
COUNT(*) AS calls,
COUNT(DISTINCT eventname) AS distinct_actions,
min(eventtime) AS first_seen,
max(eventtime) AS last_seen
FROM cloudtrail_logs
WHERE useridentity.type = 'AssumedRole'
AND regexp_like(useridentity.arn, 'assumed-role/[^/]+/i-[0-9a-f]+$')
AND sourceipaddress NOT LIKE '10.%' -- your private ranges
AND sourceipaddress NOT LIKE '%.amazonaws.com' -- AWS service-originated calls
GROUP BY 1, 2, 3
ORDER BY calls DESC;Now the off-box baseline really is zero. An instance-profile credential is issued to one instance and used by the SDKs on that instance, which call AWS from inside AWS space and show a source of ec2.amazonaws.com when the service vends them. An i-... session calling from a public address is that credential being driven from somewhere it was never issued to run. The session name persists even after theft, because an attacker holding stolen instance credentials cannot rename the session without assuming a different role, at which point they have left this identity behind anyway.
The source-IP test in that query is deliberately crude. NOT LIKE '10.%' covers one private range and misses the others, and the ec2.amazonaws.com exclusion only catches service-vended calls. Before you run this for real, define non-AWS properly against the published AWS IP ranges rather than a hand-kept list, and allowlist the egress addresses your own traffic legitimately uses, such as a NAT gateway's Elastic IP or a corporate proxy. Get that boundary right and the rule has nothing left to match except a credential genuinely in use off-box.
The same data tells you who is still on IMDSv1
Credentials vended by IMDS carry an ec2:RoleDelivery value: 1.0 when they came from IMDSv1 and 2.0 from IMDSv2. It is the authoritative marker that a credential came from the metadata service at all, and it is a free inventory of exposure, because the SSRF-to-IMDS theft only works against IMDSv1. Confirm your CloudTrail table maps the userIdentity context so the value is queryable, then read it across your instance-profile activity. Every 1.0 is an instance that will hand its credentials to the next SSRF that finds it. That list, ranked by the roles that carry real permissions, is your IMDSv2 migration backlog written for you.
Where this does not apply as written
The i-... session name is an EC2 instance-profile signal. ECS tasks receive credentials from the task metadata endpoint and carry a task identifier rather than an instance ID, and EKS pods using IAM Roles for Service Accounts assume their role through web identity, so they appear as AssumedRoleWithWebIdentity rather than an instance-profile session. If you run ECS or EKS, extend the same idea by keying on the identifier that marks those credential paths. Do not reuse the instance-ID match and assume the container fleet is covered, because it is not.
Catching the attempt, not only the use
The detection above fires once the credential is being used elsewhere. You can also catch the theft at the front of the chain, in your web logs, before the credential moves at all. The metadata address has no legitimate reason to appear inside a request URL, so its presence is the signal, not its frequency.
-- The IMDS link-local address should never appear in a request URL.
-- Its presence means something tried to read instance credentials via SSRF.
SELECT time, client_ip, request_url, elb_status_code
FROM alb_access_logs
WHERE request_url LIKE '%169.254.169.254%'
OR request_url LIKE '%fd00:ec2::254%' -- the IPv6 IMDS endpoint
ORDER BY time;Expected hits in a clean environment are zero, so this is a presence alert with no tuning curve. The IPv6 clause matters because the metadata service answers on fd00:ec2::254 as well, and an attacker who reads your stack will reach for whichever endpoint you forgot to watch. The two detections cover different moments: this one the attempt, the instance-ID query the success.
What GuardDuty covers, and the three gaps
AWS ships a managed version of this. UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWS fires when instance credentials are used from outside AWS, and since January 2022 the .InsideAWS variant fires when they are used from a different AWS account. Both are high severity. If you do nothing else this week, confirm they are on.
Three gaps are worth knowing, because they are where a capable attacker operates. First, the finding is scoped to the account, not the instance, so an attacker who compromises one instance and uses a second instance's stolen credentials from inside the same account does not trip it. Second, attackers route the stolen credentials through a VPC endpoint to make the calls look internal. AWS began detecting that bypass in October 2024 and had covered 26 services by mid-2025, but coverage is service by service, so the gaps move and are worth checking against current documentation. Third, the finding does not hand you the pivot data in a usable shape. Your own query returns the instance ID, the role, the source IP, and the exact API calls in one result, at the latency of your log delivery rather than the finding's.
Containing it
Detection is half the job, and the course this sits alongside is incident detection and response for a reason. Once you confirm an instance credential is being used off-box, it is still live until its session expires, which can be hours away. You cut it off by attaching a policy to the role that denies every action for sessions issued before now, keyed on the aws:TokenIssueTime condition. AWS exposes this as the role's revoke-sessions action, which writes an AWSRevokeOlderSessions inline policy doing exactly that. It strands the attacker's current credentials without deleting the role or breaking the instances that legitimately use it, because they request fresh credentials on their next call. Order matters here: rotating keys or tightening the role's permissions does not invalidate credentials already vended, so revoke the sessions first, then rotate. Skip the revoke and the attacker keeps the exact session you were trying to kill until it expires on its own. With the session dead, you rotate, move the instance to IMDSv2, and fix the SSRF that opened the door.
What to do this week
- Run the instance-ID query across the last 30 days. An
i-...session calling from a non-AWS IP is an incident until you prove otherwise. - Pull the
ec2:RoleDeliveryvalue for your instance-profile activity. Every1.0is an IMDSv1 instance and belongs on the migration list. - Enforce IMDSv2: set
HttpTokenstorequiredandHttpPutResponseHopLimitto1, then audit for instances still answering v1. - Grep your web and proxy logs for
169.254.169.254andfd00:ec2::254. You expect no results. - Confirm GuardDuty
InstanceCredentialExfiltrationroutes to your SOC, and have the revoke-sessions step written down as the containment action before you need it.
References
- MITRE ATT&CK, Unsecured Credentials: Cloud Instance Metadata API (T1552.005)
- AWS, CloudTrail userIdentity element (the
ec2RoleDeliveryfield) - AWS, GuardDuty IAM finding types (InstanceCredentialExfiltration)
- AWS, revoking IAM role temporary security credentials
- AWS published IP ranges (ip-ranges.json)