A migration audit on a 40-instance fleet turns up a familiar pattern: half the EC2 boxes have a ~/.aws/credentials file owned by the application user, with a long-lived access key for an IAM user that has AmazonS3FullAccess attached. The key was issued in 2019 when the team did not yet know that EC2 could get credentials from the instance metadata service. Nobody rotated it. The IAM user has logged into the console exactly zero times — only the access key gets used. Every box on the fleet shares the same key. The right answer has been instance profiles since 2014, and the path off the access-key pattern is short, but each of the three pieces — the role, the IMDSv2 enforcement, and the unused-permission cleanup — has a real-world edge case worth knowing.
What’s happening
An IAM instance profile is a container for an IAM role that an EC2 instance can assume. When the instance is launched with the profile attached, the AWS SDK on the instance can fetch short-lived credentials from the instance metadata service (IMDS) at http://169.254.169.254/latest/meta-data/iam/security-credentials/<role-name>. The credentials rotate automatically every few hours. No access key, no secret key, no file to forget about.
The anti-pattern that the migration found — a long-lived access key on disk — is worse than it looks. The key is a permanent credential. If the instance is ever compromised (a web shell, a vulnerable dependency, a developer with a stale laptop), the attacker walks away with a credential that works from anywhere and can be replayed for days before anyone notices. The instance-profile credentials, by contrast, are SigV4-signed against the instance’s role and bounded to a short TTL. An exfiltrated metadata credential expires before the attacker can do much with it from outside.
IMDSv1 is the catch. The original metadata service is request-response over HTTP with no authentication — any process on the instance, including a process running inside a container that the user did not realize had host network access, can read it. The well-publicized SSRF-to-credential-theft pattern (forge a request from a misconfigured web server to 169.254.169.254, exfiltrate the role credentials) exists because IMDSv1 has no defense. IMDSv2 fixes this by requiring a session token obtained via PUT — an SSRF that can only issue GET requests cannot complete the handshake. New AWS accounts default to IMDSv2-required since 2024, but instances launched from older AMIs or older Terraform modules may still allow IMDSv1.
The cleanup question is separate. An instance profile attached at launch tends to accumulate permissions over the years — someone needs S3 read for a backup job, someone else needs DynamoDB write for a feature, the role ends up with eight managed policies attached and uses three of them. IAM Access Analyzer’s unused-access feature surfaces this.
How to verify
The fleet-level audit takes three queries.
First, list every EC2 instance and the IAM role attached (or None):
aws ec2 describe-instances \
--query 'Reservations[].Instances[].[InstanceId,IamInstanceProfile.Arn,Tags[?Key==`Name`].Value|[0]]' \
--output table
Any instance with None in the IAM column is either using a credential file or has no AWS access at all. Run find / -name credentials -path '*/.aws/*' 2>/dev/null on a sample of those instances to confirm.
Second, check the IMDS configuration on every instance:
aws ec2 describe-instances \
--query 'Reservations[].Instances[].[InstanceId,MetadataOptions.HttpTokens,MetadataOptions.HttpEndpoint]' \
--output table
HttpTokens should read required (IMDSv2-only). If it reads optional, the instance still accepts IMDSv1 requests. HttpEndpoint should be enabled unless the instance intentionally has no need for metadata access.
Third, for the cleanup pass, enable IAM Access Analyzer’s unused-access analyzer (separate from the external-access analyzer):
aws accessanalyzer create-analyzer \
--analyzer-name unused-access-prod \
--type ACCOUNT_UNUSED_ACCESS \
--configuration '{"unusedAccess":{"unusedAccessAge":90}}'
After the analyzer runs (a few hours for a fresh account), list findings:
aws accessanalyzer list-findings-v2 \
--analyzer-arn arn:aws:access-analyzer:us-east-1:123456789012:analyzer/unused-access-prod \
--filter '{"findingType":{"eq":["UnusedPermission","UnusedIAMRole","UnusedIAMUserAccessKey"]}}'
The UnusedIAMUserAccessKey findings are the long-lived access keys on disk that nobody is using actively — they are the ones to retire first. UnusedPermission is the policy-level cleanup.
The fix
The migration order: instance profile first, then IMDSv2 enforcement, then permission cleanup.
-
Create the role with a narrowly-scoped trust policy and the permissions the application actually needs (not
AmazonS3FullAccess). For an app that reads froms3://app-uploads-prod/:{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], "Resource": ["arn:aws:s3:::app-uploads-prod/*"] }] }No bucket-level
s3:*, no wildcard buckets, noiam:*. -
Attach the role to the instance. For a running instance:
aws ec2 associate-iam-instance-profile \ --instance-id i-0123456789abcdef0 \ --iam-instance-profile Name=app-prod-roleThe SDK on the instance picks up the role credentials within a few seconds. Verify with
aws sts get-caller-identityfrom the instance — it should return the assumed-role ARN, not the IAM user. -
Remove the credentials file.
rm /home/app/.aws/credentialson the instance, then revoke the IAM user’s access key:aws iam update-access-key \ --user-name app-deploy-user \ --access-key-id AKIA... \ --status InactiveLeave the key inactive for two weeks before deleting. If anything was still using the key from outside the instance, the failure will surface within that window.
-
Enforce IMDSv2 on the instance. The change is non-disruptive for any SDK released after 2020, but worth applying with eyes open on legacy AMIs:
aws ec2 modify-instance-metadata-options \ --instance-id i-0123456789abcdef0 \ --http-tokens required \ --http-endpoint enabled \ --http-put-response-hop-limit 1The hop limit of 1 prevents containers in bridge mode from reaching the metadata service. If the workload needs containers to access AWS (and uses the SDK from inside the container), use IRSA on EKS or task roles on ECS instead of bumping the hop limit.
-
After the instance profile has been live for at least 90 days, run the Access Analyzer unused-permission pass and trim the role policy to only the actions that the role has actually called. The analyzer’s
UnusedPermissionfinding lists each action that was never invoked — drop those from the policy. The role narrows over time without ever breaking the application, because the analyzer only reports actions the application is provably not using.
Three rules across every IAM cleanup we run:
Roles, not users, on EC2. An IAM user with an access key on disk is a 2014 pattern. There is no scenario in 2026 where it is the right answer for code running on an EC2 instance.
IMDSv2 required, hop limit 1. Default behavior on new accounts since 2024, but legacy fleets still drift. The two flags together cost nothing and remove a class of SSRF-to-credential-theft attacks.
Trim permissions on a 90-day cadence. Unused-access findings get stale if nobody acts on them. The cadence runs alongside the cost review — a quarterly cleanup loop catches drift before it becomes audit material.
The whole sequence on a 40-instance fleet is a focused afternoon for the migration plus an hour every quarter for the trim. Nothing fancy. The savings are not on the bill — they are on the next incident that does not happen because a long-lived key was not sitting in a directory waiting to be found.
Stack Harbor runs IAM cleanup, CloudWatch baseline, AWS Backup, and the cost-control rhythm as part of Managed AWS and ongoing Managed Cloud Ops. If long-lived access keys still surface in your audits, start with an infrastructure consult.