Detecting IAM persistence: attacker-created access keys
Creating a second access key on a compromised IAM user is one of the quietest persistence mechanisms in AWS. Here is what it looks like in CloudTrail, and a detection that survives contact with production.
An attacker who lands a set of AWS credentials has an immediate problem: those credentials can be rotated out from under them at any moment. The cheapest fix is to mint a second set.
CreateAccessKey is a single API call, needs no additional privilege beyond IAM self-service,
and produces credentials that survive a password reset, an MFA enrolment, and most incident
response that stops at “we rotated the key”. It is a persistence primitive that hides inside
entirely ordinary administrative traffic.
What it looks like from the attacker side#
The whole technique is one call:
# Enumerate what the current identity can reach
aws sts get-caller-identity
aws iam list-attached-user-policies --user-name svc-deploy
# Mint a second key for the same principal
aws iam create-access-key --user-name svc-deploy
An IAM user is permitted two access keys. If the account already has two, the attacker
deletes the older one first — which is itself a useful signal, since a DeleteAccessKey
immediately followed by a CreateAccessKey for the same principal is far less common than
either call alone.
The CloudTrail event#
The resulting record is unremarkable at a glance. Trimmed to the fields that matter:
{
"eventSource": "iam.amazonaws.com",
"eventName": "CreateAccessKey",
"eventTime": "2026-08-12T02:14:51Z",
"sourceIPAddress": "203.0.113.44",
"userAgent": "aws-cli/2.15.30 Python/3.11.8 Linux/6.5.0",
"userIdentity": {
"type": "IAMUser",
"userName": "svc-deploy",
"accessKeyId": "AKIAIOSFODNN7EXAMPLE",
"sessionContext": {
"attributes": { "mfaAuthenticated": "false" }
}
},
"requestParameters": { "userName": "svc-deploy" },
"responseElements": {
"accessKey": {
"userName": "svc-deploy",
"accessKeyId": "AKIAI44QH8DHBEXAMPLE",
"status": "Active"
}
}
}
Two things in that record carry most of the detection value:
requestParameters.userNameequalsuserIdentity.userName. The principal created a key for itself. Legitimate key creation is overwhelmingly an administrator acting on someone else’s behalf, or automation with a distinct role.mfaAuthenticatedisfalse. A human administrator in a mature account is almost always MFA-backed. Automation is not — which is exactly why the next section matters.
Self-service key creation is not inherently malicious. Plenty of engineers rotate their own keys. The signal is not the call; it is the call arriving from a principal, an IP, or an hour that has no history of making it.
Baselining before you alert#
Deploying this as a raw alert produces noise on day one. The version that works compares against the principal’s own history.
// Azure Data Explorer / Sentinel — CloudTrail ingested as AWSCloudTrail
let lookback = 30d;
let window = 1d;
// Principals that have created keys before are the expected population.
let known =
AWSCloudTrail
| where TimeGenerated between (ago(lookback) .. ago(window))
| where EventName == "CreateAccessKey"
| distinct UserIdentityUserName;
AWSCloudTrail
| where TimeGenerated > ago(window)
| where EventName == "CreateAccessKey"
| extend TargetUser = tostring(RequestParameters.userName)
| extend SelfService = TargetUser == UserIdentityUserName
| where UserIdentityUserName !in (known)
| project
TimeGenerated,
Actor = UserIdentityUserName,
TargetUser,
SelfService,
SourceIpAddress,
UserAgent,
MfaAuthenticated = tostring(UserIdentitySessionContext.attributes.mfaAuthenticated)
| order by TimeGenerated desc
The equivalent as a Sigma rule, for pushing to a SIEM that consumes them:
title: AWS IAM Self-Service Access Key Creation Without MFA
id: 8f2c1a94-3e77-4b0d-9c61-2a5e7d10b4f3
status: experimental
description: >
Detects an IAM principal creating an access key for itself in a session that
was not MFA-authenticated. Common persistence step after credential theft.
logsource:
product: aws
service: cloudtrail
detection:
selection:
eventSource: iam.amazonaws.com
eventName: CreateAccessKey
userIdentity.sessionContext.attributes.mfaAuthenticated: 'false'
filter_automation:
userIdentity.type: AssumedRole
userIdentity.arn|contains: ':role/ci-'
condition: selection and not filter_automation
falsepositives:
- Engineers rotating their own long-lived keys
- Bootstrap automation running under a user rather than a role
level: medium
tags:
- attack.persistence
- attack.t1098.001
Tuning: what to exclude and why#
| Exclusion | Rationale | Risk if over-applied |
|---|---|---|
CI/CD roles (:role/ci-*) | Bootstrap pipelines legitimately mint keys | Attacker assumes a CI role and inherits the blind spot |
| Break-glass admin user | Emergency access is expected to be unusual | Break-glass is a prime target; never exclude, alert louder |
| Known IdP-federated admins | High-volume, MFA-backed, well-attributed | Federated session hijack becomes invisible |
| First 24h of a new account | Provisioning churn | Attacker times the action to onboarding |
The general rule: exclude on identity plus context, never identity alone. role/ci-deploy
creating a key during a pipeline run is expected; the same role doing it at 02:00 from a
residential IP is not.
Validating the detection#
Don’t trust a rule you haven’t fired deliberately. This creates a key, confirms the event lands, and cleans up after itself:
import boto3
import time
iam = boto3.client("iam")
TEST_USER = "detection-validation-ephemeral"
iam.create_user(UserName=TEST_USER)
key = iam.create_access_key(UserName=TEST_USER)["AccessKey"]
print(f"created {key['AccessKeyId']} — expect an alert within the SIEM's ingest lag")
# CloudTrail delivery is not instant; budget for the lag before asserting.
time.sleep(900)
iam.delete_access_key(UserName=TEST_USER, AccessKeyId=key["AccessKeyId"])
iam.delete_user(UserName=TEST_USER)
print("cleaned up")
Run it against a non-production account, and record the observed end-to-end latency. That number is the honest answer to “how fast would we catch this?” — and it is almost always slower than people assume.
What this misses#
Being clear about coverage gaps matters more than the rule itself:
- Role chaining. An attacker who assumes a role rather than minting a key never touches
CreateAccessKey. Pair this withAssumeRoleanomaly detection. - Console session theft. Stolen browser session cookies bypass the API entirely.
- Cross-account trust abuse. A modified trust policy is a quieter path to durable access;
watch
UpdateAssumeRolePolicy.
Access key persistence is worth detecting because it’s cheap for the attacker and cheap for you. It is not worth mistaking for full coverage of AWS persistence.
Detection logic here is written against CloudTrail management events. If you’re sampling data
events or excluding read-only calls, verify your trail actually captures iam.amazonaws.com
before relying on any of it.