Access keys, leaks and CloudTrail
An access key is a password that never expires, works from anywhere on the internet, and is very easy to paste into the wrong place. Leaked keys are the most common way AWS accounts get broken into, and bots harvest them from public GitHub within minutes of a push. This lesson is the lifecycle of a key - create, rotate, retire - what happens when one leaks, and CloudTrail, the record you investigate with.
Need to know: a long-term key is an ID (AKIA..., not secret) plus a 40-character secret shown once, at creation. A user can have two, each Active or Inactive. get-access-key-last-used and the credential report show which keys are alive. Rotate by overlapping: create a second key, deploy it, check the old one stopped being used, deactivate, delete. When a key leaks: deactivate it first, investigate second - CloudTrail event history has 90 days of management events per Region, and IAM's events are in us-east-1.
The key's lifecycle
$ aws iam list-access-keys --user-name try-backup-bot --output table
-------------------------------------------------------------------------------------
| ListAccessKeys |
+-----------------------------------------------------------------------------------+
|| AccessKeyMetadata ||
|+-----------------------+-----------------------------+---------+-----------------+|
|| AccessKeyId | CreateDate | Status | UserName ||
|+-----------------------+-----------------------------+---------+-----------------+|
|| AKIALGOOLXVECZTLWTOB | 2025-08-06T20:00:03+00:00 | Active | try-backup-bot ||
|| AKIAGFYOXXCC2KJIZZUI | 2026-09-19T20:00:03+00:00 | Active | try-backup-bot ||
|+-----------------------+-----------------------------+---------+-----------------+|
$ aws iam get-access-key-last-used --access-key-id $(aws iam list-access-keys --user-name try-backup-bot --query 'AccessKeyMetadata[0].AccessKeyId' --output text)
{
"UserName": "try-backup-bot",
"AccessKeyLastUsed": {
"LastUsedDate": "2026-09-22T14:00:03+00:00",
"ServiceName": "s3",
"Region": "eu-central-1"
}
}
$ aws iam get-access-key-last-used --access-key-id $(aws iam list-access-keys --user-name try-backup-bot --query 'AccessKeyMetadata[1].AccessKeyId' --output text)
{
"UserName": "try-backup-bot",
"AccessKeyLastUsed": {
"ServiceName": "N/A",
"Region": "N/A"
}
}
$ aws iam create-access-key --user-name try-backup-bot
aws: [ERROR]: An error occurred (LimitExceeded) when calling the CreateAccessKey operation: Cannot exceed quota for AccessKeysPerUser: 2
Three facts from three commands:
list-access-keysnever shows a secret: AWS does not keep it in a form it can give back. Lose the secret and the only fix is a new key.- The second key was created three days ago and never used (
N/A): someone started a rotation and did not finish it - common, and dangerous, because now two keys are valid. - A user cannot have a third key (
LimitExceeded). The quota is what makes rotation a dance: there is always room for exactly one new key.
The credential report is the account-wide view: one CSV row per user with password and key ages and last use. Auditors ask for it; you can read it in the shell:
$ aws iam generate-credential-report
{
"State": "STARTED",
"Description": "No report exists. Starting a new report generation task"
}
$ sleep 6
$ aws iam generate-credential-report
{
"State": "COMPLETE"
}
$ aws iam get-credential-report --query Content --output text | base64 -d | cut -d, -f1,9,10,11 | column -t -s,
user access_key_1_active access_key_1_last_rotated access_key_1_last_used_date
<root_account> false N/A N/A
learner true 2026-08-23T20:00:03+00:00 2026-09-22T20:00:10+00:00
try-backup-bot true 2025-08-06T20:00:03+00:00 2026-09-22T14:00:03+00:00
Rotating without an outage
The safe rotation of a key that a running system uses:
- Create a second key (
create-access-key) - the old one keeps working. - Deploy the new key to every place that uses the old one (the secret store, the CI variable, the config file).
- Verify:
get-access-key-last-usedof the new key shows a recent date, of the old one stops moving. Wait at least one full cycle of the job (a nightly job: a night). - Deactivate the old key (
update-access-key --status Inactive) - reversible in one command if something still breaks. - Delete it after a quiet period (
delete-access-key).
Skipping step 3 is how a rotation becomes an incident at 02:00. Skipping step 5 leaves a valid credential lying around. Better than all of it: make the key unnecessary - people through IAM Identity Center, workloads through roles, CI through OIDC (the previous lesson). AWS's own guidance is to rotate keys that must exist regularly and to remove unused ones; Access Analyzer's unused access findings list keys nobody has used for a while.
When a key leaks
Bots scan public repositories, package registries and paste sites continuously; a key pushed to public GitHub is typically tried within minutes. AWS scans too. When it finds one of your keys in public, it:
- sends an email ("Your AWS Access Key is Exposed") and creates an AWS Health event of type
AWS_RISK_CREDENTIALS_EXPOSED, and opens a support case; - attaches the AWS managed policy AWSCompromisedKeyQuarantineV3 to the user - explicit denies on what attackers do first (creating users and keys,
ec2:RunInstances, changing policies,s3:GetObject,s3:ListBucket,cloudtrail:LookupEvents...).
$ aws iam get-policy-version --policy-arn arn:aws:iam::aws:policy/AWSCompromisedKeyQuarantineV3 --version-id v3 --query 'PolicyVersion.Document.Statement[0].Action[?starts_with(@, `iam:`)] | length(@)'
26
$ aws iam get-policy-version --policy-arn arn:aws:iam::aws:policy/AWSCompromisedKeyQuarantineV3 --version-id v3 --query 'PolicyVersion.Document.Statement[0].Action' --output text | tr '\t' '\n' | grep -c .
98
The quarantine is damage limitation, not a fix: it blocks a list of actions, and anything not on the list (reading other services' data, s3:PutObject) still works with the key. Your response, in order:
- Contain: deactivate the key (
update-access-key --status Inactive) as a different principal - the quarantine policy denies the leaked user's owniam:UpdateAccessKey. - Investigate: CloudTrail by
AccessKeyId, in every Region you use and us-east-1 (IAM, STS's global endpoint), for the window since the push. Look for what succeeded: new users, keys, roles, instances, changed policies. - Eradicate: delete what the attacker created; issue a new key (or better, replace the key with a role); delete the leaked key.
- Recover and learn: confirm the service works with the new credential, check what data the key could read or write (management events do not show object reads), remove the secret from the repository - and accept that git history rewriting does not un-leak it: clones exist.
aws sts get-access-key-info tells you which account a key ID belongs to - useful when a key turns up in a log or a repo and nobody knows whose it is:
$ aws sts get-access-key-info --access-key-id $(aws iam list-access-keys --user-name try-backup-bot --query 'AccessKeyMetadata[0].AccessKeyId' --output text)
{
"Account": "111122223333"
}
$ aws iam get-access-key-last-used --access-key-id $(aws iam list-access-keys --user-name try-backup-bot --query 'AccessKeyMetadata[0].AccessKeyId' --output text) --query UserName --output text
try-backup-bot
CloudTrail: who did what, when, from where
CloudTrail records API calls. Without any setup, every account has event history: the last 90 days of management events (control-plane calls: CreateUser, PutBucketPolicy, RunInstances, AssumeRole...) per Region, searchable with aws cloudtrail lookup-events. For longer retention, all Regions in one place, or data events (S3 object reads and writes, Lambda invocations) you create a trail that delivers log files to S3 (or use CloudTrail Lake) - AWS III.
$ aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=CreateAccessKey --query 'Events[].[EventTime,Username,EventName]' --output text
$ aws cloudtrail lookup-events --region us-east-1 --lookup-attributes AttributeKey=EventName,AttributeValue=CreateAccessKey --query 'Events[].[EventTime,Username,EventName]' --output text
2026-09-22T20:00:04+00:00 learner CreateAccessKey
Nothing in eu-central-1, the event in us-east-1: IAM is global and its events live there. "CloudTrail shows nothing" is very often "you looked in the wrong Region". Each event carries the full record as a JSON string in CloudTrailEvent:
$ aws cloudtrail lookup-events --region us-east-1 --max-items 1 --lookup-attributes AttributeKey=EventName,AttributeValue=CreateAccessKey --query 'Events[0].CloudTrailEvent' --output text | jq '{who: .userIdentity.arn, key: .userIdentity.accessKeyId, from: .sourceIPAddress, agent: .userAgent, error: .errorCode, created: .responseElements.accessKey.accessKeyId}'
{
"who": "arn:aws:iam::111122223333:user/learner",
"key": "AKIAQ3EGPLAB4LEARNER",
"from": "198.51.100.24",
"agent": "aws-cli/2.37.10 md/awscrt#0.37.0 ua/2.1 os/linux#7.0.0-31-generic md/arch#aarch64 lang/python#3.14.6 md/pyimpl#CPython m/E,b cfg/retry-mode#standard md/installer#exe md/distrib#ubuntu.26 md/prompt#off md/command#iam.create-access-key",
"error": "LimitExceeded",
"created": null
}
$ aws cloudtrail lookup-events --lookup-attributes AttributeKey=Username,AttributeValue=try-backup-bot --query 'Events[].[EventTime,EventSource,EventName]' --output text
2026-09-22T14:00:03+00:00 s3.amazonaws.com ListBuckets
The fields you read in an investigation: userIdentity (type, ARN, the access key, for roles the session issuer), eventSource + eventName (the API call), sourceIPAddress and userAgent (a laptop's CLI, a Python script, an AWS service), errorCode/errorMessage (failed attempts are recorded too - a burst of AccessDenied from an unknown IP is the signature of someone probing a stolen key), and requestParameters/responseElements.
lookup-events takes one lookup attribute at a time (EventName, Username, AccessKeyId, ResourceName, ResourceType, EventSource, ReadOnly, EventId) plus a time window (--start-time, --end-time); combine further with --query or jq. Events usually appear within about 5 minutes of the call.
In an interview: "A developer pushed an access key to a public repo. What do you do?" - "Contain first: deactivate the key immediately. Then investigate with CloudTrail by access key ID in every Region and us-east-1 for what it did - new users, keys, roles, instances. Delete what the attacker created, issue a replacement (ideally a role or OIDC instead of a key), delete the leaked key, and check what data it could reach. Removing the commit is not enough; the key is burned the moment it is public."
You can now: read a user's keys and their last use, run a rotation without an outage, pull the credential report, explain what AWS does with an exposed key and what you must do yourself, and search CloudTrail event history in the right Region and read an event record.