$heversec
3 min read

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:

  1. requestParameters.userName equals userIdentity.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.
  2. mfaAuthenticated is false. 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#

ExclusionRationaleRisk if over-applied
CI/CD roles (:role/ci-*)Bootstrap pipelines legitimately mint keysAttacker assumes a CI role and inherits the blind spot
Break-glass admin userEmergency access is expected to be unusualBreak-glass is a prime target; never exclude, alert louder
Known IdP-federated adminsHigh-volume, MFA-backed, well-attributedFederated session hijack becomes invisible
First 24h of a new accountProvisioning churnAttacker 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 with AssumeRole anomaly 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.