This page covers Amazon Web Services Identity & Access Management hardening across the surfaces that determine whether an attacker who lands a credential can pivot to full account takeover. AWS exposes a larger root-and-Organizations surface than the other three providers in this guide, so the control inventory here is ten items rather than the eight on the Azure, GCP, and OCI sibling pages — the additional controls reflect AWS's root-account model and its Service Control Policy (SCP) machinery at the Organizations layer. Scope is commercial AWS regions; AWS GovCloud (US) and the China regions inherit the same controls but require region-specific endpoints (for example iam.us-gov.amazonaws.com) and have their own STS partitions. CIS sub-IDs and NIST/ISO mappings throughout this page reference the AWS commercial benchmark unless noted.
The mental model: AWS IAM is the product of identity policies (what a principal can do, attached to users, groups, roles), resource policies (who can touch a resource, attached to S3 buckets, KMS keys, etc.), Service Control Policies (organisation-wide upper bound on what any principal in a member account may do), and permission boundaries (per-principal upper bound used when delegating admin rights). The cross-cutting principles — least privilege, separation of duties, credential rotation, secrets management, MFA — are explained in the General IAM page; this page maps them to AWS primitives. Severity assignments follow the rubric documented in methodology, in particular the worked example EX-MFA-01 which derives a CRITICAL PREVENTIVE for the canonical phishing-resistant MFA case. Equivalence callouts at the bottom of each control point to the matching control on the Azure, GCP, and OCI pages so a reader can compare modelling across providers.
Order matters in this list. Controls 01–04 are CRITICAL PREVENTIVE and address the single biggest residual risk in nearly every audited AWS account: standing credentials with MFA gaps. Controls 05–08 are HIGH/MEDIUM PREVENTIVE and progressively replace long-lived credentials with short-lived role assumption, then fence the blast radius with permission boundaries and SCPs. Controls 09–10 are DETECTIVE: IAM Access Analyzer continuously discovers unintended external access; credential-report auditing surfaces drift in users that pre-date later preventive controls. Reviewing the compliance-frameworks page first will clarify why each control row lists CIS, NIST 800-53 rev5, and ISO 27001/27017 cells in the same order across all four provider pages.
aws-iam-01-root-mfa!CRITICALPREVENTIVE
Enable a hardware FIDO2 security key (or, at minimum, a virtual TOTP device backed up offline) as the MFA device on the root user of every AWS account, including every member account of an AWS Organization. AWS announced in 2024 that root-user MFA is becoming mandatory for the management account and is being progressively rolled out across member accounts; planning teams should treat this as an in-flight enforcement rather than an optional best practice (AWS Security Blog — root MFA mandate announcement 2024 (accessed 2026-05)). The principle is reinforced in the General IAM — MFA section and the severity derivation is worked end-to-end in methodology EX-MFA-01.
Remediation — AWS CLI
# Hardware/virtual root MFA is registered through the console (Security credentials
# > Multi-factor authentication). The CLI cannot enrol the root user's MFA device
# because the root principal cannot be assumed programmatically. After enrolment,
# verify from any admin-credentialed session:
aws iam get-account-summary \
--query 'SummaryMap.AccountMFAEnabled' \
--output text
# Expected: 1
Remediation — Terraform
# Terraform AWS provider ~> 5.0
# Root MFA enrolment itself is a console action; this SCP enforces the
# organisational invariant "no root action without MFA present" against
# every member account.
resource "aws_organizations_policy" "deny_root_without_mfa" {
name = "deny-root-without-mfa"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "DenyRootWithoutMFA"
Effect = "Deny"
Action = "*"
Resource = "*"
Condition = {
Bool = { "aws:MultiFactorAuthPresent" = "false" }
StringLike = { "aws:PrincipalArn" = "arn:aws:iam::*:root" }
}
}]
})
}
Remediation — CloudFormation
AWSTemplateFormatVersion: '2010-09-09'
Description: SCP enforcing "no root action without MFA present" across member accounts.
Parameters:
OrgRootId:
Type: String
Description: AWS Organizations root or OU id to attach the SCP to.
Resources:
DenyRootWithoutMfa:
Type: AWS::Organizations::Policy
Properties:
Name: deny-root-without-mfa
Type: SERVICE_CONTROL_POLICY
TargetIds:
- !Ref OrgRootId
Content:
Version: '2012-10-17'
Statement:
- Sid: DenyRootWithoutMFA
Effect: Deny
Action: '*'
Resource: '*'
Condition:
Bool:
aws:MultiFactorAuthPresent: 'false'
StringLike:
aws:PrincipalArn: 'arn:aws:iam::*:root'
Run in CloudWatch Logs Insights against the CloudTrail organisation-trail log group. Pin to the management account first, then pivot per-member-account via cross-account log delivery.
Alert threshold
Any single ConsoleLogin with userIdentity.type = "Root" AND MFAUsed = "No" — page on first occurrence.
Any DeactivateMFADevice on a root principal during a 24h window — page on first occurrence.
Baseline calibration: tune over a 30-day window using stats count() by userIdentity.accountId to confirm expected root-login cadence per account (typically near zero).
Initial response
Verify the login against the documented break-glass ticket and the named individual who holds the root hardware key; if no ticket exists, treat as confirmed compromise.
Force root credential rotation: trigger the documented root-password reset flow + re-enrol the hardware MFA device via the AWS console; rotate any root-tagged signing keys.
Escalate per general/ir.html — open an incident, snapshot CloudTrail + Config + GuardDuty findings for the affected account, and run the SCP audit (aws-iam-08-scp-deny-list) to confirm preventive guards still apply.
The root user must hold zero long-lived access keys. AWS has documented for many years that the root user should be used only for the small set of actions that nothing else can perform (closing the account, changing payment instruments, registering as a seller); using a root access key for routine workload calls is the highest-leverage credential compromise possible (AWS IAM User Guide — security best practices (accessed 2026-05)). Confirm both that no key currently exists and that the account-wide policy forbids creating one.
Remediation — AWS CLI
# Audit: confirm root has zero access keys (uses credential-report data).
aws iam generate-credential-report
aws iam get-credential-report \
--query 'Content' --output text \
| base64 -d \
| awk -F, 'NR==1 || $1=="<root_account>"' \
| cut -d, -f1,9,11
# Expect access_key_1_active=false and access_key_2_active=false.
# If a root key exists, delete it (must be run as root from the console / CLI):
aws iam delete-access-key --access-key-id <AKIA...>
Remediation — Terraform
# Terraform AWS provider ~> 5.0
# Organisation-wide SCP denying the CreateAccessKey API when the calling
# principal is the root user of any member account.
resource "aws_organizations_policy" "deny_root_create_access_key" {
name = "deny-root-create-access-key"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "DenyRootCreateAccessKey"
Effect = "Deny"
Action = ["iam:CreateAccessKey", "iam:UpdateAccessKey"]
Resource = "*"
Condition = {
StringLike = { "aws:PrincipalArn" = "arn:aws:iam::*:root" }
}
}]
})
}
CloudTrail CreateAccessKey where userIdentity.type = "Root" — any successful invocation regardless of source IP.
CloudTrail UpdateAccessKey targeting an access-key whose owning user resolves to the root principal (cross-reference requestParameters.userName against the credential-report root row).
IAM credential-report deltas where access_key_1_active or access_key_2_active flips from false to true on the <root_account> row between consecutive scheduled report runs.
Query
fields @timestamp, eventName, userIdentity.arn, sourceIPAddress, requestParameters.userName, errorCode
| filter eventName in ["CreateAccessKey","UpdateAccessKey"]
| filter userIdentity.type = "Root" or requestParameters.userName like /:root$/
| sort @timestamp desc
| limit 50
Target the CloudWatch Logs Insights query at the organisation-trail log group; pivot the same filter into each member-account trail when the SCP denies the call so that the deny itself is the high-confidence signal.
Alert threshold
Any successful CreateAccessKey from a root principal — page on first occurrence; the expected steady-state rate is exactly zero.
Any CreateAccessKey denied by the SCP from aws-iam-08-scp-deny-list — informational alert per occurrence, batched at 1-hour cadence (SCP denials confirm the preventive guard fired and may correlate with phishing reconnaissance).
Tune: feed the prior 30 days into a static allow-list of approved automation principals; deviation against that list is the actionable signal.
Initial response
Pull the credential report immediately and confirm whether access_key_1_active or access_key_2_active is now true on the root row; capture the CSV as forensic evidence before any remediation touches the account.
If the key was created: delete it via aws iam delete-access-key using the root console session (programmatic deletion requires the root key the attacker just created — coordinate with the named root-key holder); then re-confirm the SCP from aws-iam-08-scp-deny-list is attached to every OU.
Open an incident per general/ir.html; correlate the CloudTrail event source IP against known break-glass jump-host CIDRs and treat any deviation as confirmed compromise.
Human access to AWS accounts should flow through IAM Identity Center — the service formerly known as AWS SSO, which AWS renamed in 2022; new content should never use the old name (AWS IAM Identity Center user guide (accessed 2026-05)). Identity Center federates from an upstream identity provider (Okta, Entra ID, JumpCloud, Identity Center's own directory) and issues short-lived role-session credentials per account through permission sets, replacing long-lived IAM users for humans.
Remediation — AWS CLI
# Create a permission set scoped to a managed policy.
aws sso-admin create-permission-set \
--instance-arn arn:aws:sso:::instance/<SSOINS_ID> \
--name ReadOnlyAuditors \
--session-duration PT4H
# Attach the managed policy and assign to a group in a member account.
aws sso-admin attach-managed-policy-to-permission-set \
--instance-arn arn:aws:sso:::instance/<SSOINS_ID> \
--permission-set-arn arn:aws:sso:::permissionSet/<PS_ID> \
--managed-policy-arn arn:aws:iam::aws:policy/ReadOnlyAccess
AWSTemplateFormatVersion: '2010-09-09'
Description: AWS IAM Identity Center permission set with session duration and managed policy boundary.
Parameters:
InstanceArn:
Type: String
Description: IAM Identity Center instance ARN (sso:DescribeInstances).
Resources:
ReadOnlyPermissionSet:
Type: AWS::SSO::PermissionSet
Properties:
Name: org-read-only
Description: SSO permission set granting AWS-managed ReadOnlyAccess.
InstanceArn: !Ref InstanceArn
SessionDuration: PT1H
ManagedPolicies:
- arn:aws:iam::aws:policy/ReadOnlyAccess
Compliance mapping
CIS AWS Foundations v3.0.0
CIS Microsoft Azure Foundations v3.0.0
CIS GCP Foundation v4.0.0
CIS OCI Foundation v2.0.0
NIST SP 800-53 rev5
ISO/IEC 27001:2022
ISO/IEC 27017:2015
1.21
1.1.4
best-practices
best-practices
AC-2(1); IA-2
A.5.16; A.5.18
n/a
Log signals
CloudTrail ConsoleLogin where userIdentity.type = "IAMUser" (rather than the expected AssumedRole reaching the account from an Identity Center permission-set role) — surfaces humans bypassing the federated path.
CloudTrail sso:DisassociatePermissionSet, sso:DeleteAccountAssignment, or sso-admin:DeletePermissionSet — covers tampering with the Identity Center configuration itself.
Standing IAM-user inventory drift: scheduled query of iam:ListUsers result-count crossing a static baseline upward indicates new long-lived human accounts being created outside Identity Center.
Query
fields @timestamp, eventName, userIdentity.type, userIdentity.userName, recipientAccountId, sourceIPAddress
| filter eventName = "ConsoleLogin" and userIdentity.type = "IAMUser"
| filter userIdentity.userName not like /^break-glass-/
| stats count() as logins by userIdentity.userName, recipientAccountId
| sort logins desc
This CloudWatch Logs Insights query groups direct IAM-user console sign-ins by user and member account, with the documented break-glass naming convention stripped out so the residual set is precisely the deviation surface.
Alert threshold
Any IAM-user console sign-in outside the documented break-glass naming convention — page on the first event in a 24h window per identity, then suppress further pages from the same identity for 24h to avoid flap during remediation.
Net-new IAM users created in any member account between Identity Center adoption date and the present — informational digest at weekly cadence, escalate if the digest is non-empty for two consecutive weeks.
Baseline calibration: window the prior 30 days of ConsoleLogin events, list distinct IAMUser identities, validate the list against the IT-onboarding ledger before tightening the page rule.
Initial response
Confirm via the named individual whether the IAM-user sign-in is a sanctioned break-glass session; if yes, capture the change-management ticket ID and close as documented exception.
If unsanctioned: disable the IAM user's console password via aws iam delete-login-profile, deactivate any attached access keys, and provision the human's correct Identity Center group membership instead.
Run the orphan-IAM-user audit (cross-reference aws iam list-users against the HRIS active-employee list) and forward the diff to general/ir.html as an IAM-hygiene finding rather than an active incident if no credential abuse is evident.
For accounts that still contain IAM users with console passwords — typically legacy accounts pre-dating Identity Center, plus break-glass users — every such user must have an MFA device enrolled and a policy condition that denies all actions until MFA is presented. CIS AWS Foundations v3.0.0 control 1.10 mandates this for any IAM user with a console password (CIS Amazon Web Services Foundations Benchmark v3.0.0 — Jan 2024 release (accessed 2026-05)).
Remediation — AWS CLI
# List users with console passwords and check whether each has an MFA device.
aws iam list-users --query 'Users[].UserName' --output text \
| tr '\t' '\n' \
| while read u; do
pw=$(aws iam get-login-profile --user-name "$u" 2>/dev/null && echo yes || echo no)
mfa=$(aws iam list-mfa-devices --user-name "$u" \
--query 'length(MFADevices)' --output text)
printf '%s\tconsole=%s\tmfa_devices=%s\n' "$u" "$pw" "$mfa"
done
Remediation — Terraform
# Terraform AWS provider ~> 5.0
# Group-attached policy denying every action when MFA is not present in the
# session. Attach to every group containing IAM users with console access.
resource "aws_iam_group_policy" "require_mfa" {
name = "require-mfa"
group = aws_iam_group.console_users.name
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "DenyAllExceptListedIfNoMFA"
Effect = "Deny"
NotAction = [
"iam:CreateVirtualMFADevice",
"iam:EnableMFADevice",
"iam:GetUser",
"iam:ListMFADevices",
"iam:ListVirtualMFADevices",
"iam:ResyncMFADevice",
"sts:GetSessionToken"
]
Resource = "*"
Condition = {
BoolIfExists = { "aws:MultiFactorAuthPresent" = "false" }
}
}]
})
}
resource "aws_iam_account_password_policy" "strong" {
minimum_password_length = 14
require_symbols = true
require_numbers = true
require_uppercase_characters = true
require_lowercase_characters = true
allow_users_to_change_password = true
password_reuse_prevention = 24
max_password_age = 90
}
import * as cdk from 'aws-cdk-lib';
import { aws_iam as iam } from 'aws-cdk-lib';
import { Construct } from 'constructs';
export class DenyAllWithoutMfaStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
new iam.ManagedPolicy(this, 'DenyAllWithoutMfa', {
managedPolicyName: 'deny-all-without-mfa',
document: new iam.PolicyDocument({
statements: [
new iam.PolicyStatement({
sid: 'AllowSelfServiceMfaManagement',
effect: iam.Effect.ALLOW,
actions: [
'iam:ChangePassword',
'iam:GetUser',
'iam:CreateVirtualMFADevice',
'iam:EnableMFADevice',
'iam:ListMFADevices',
'iam:ResyncMFADevice',
],
resources: [`arn:aws:iam::${this.account}:user/\${aws:username}`],
}),
new iam.PolicyStatement({
sid: 'DenyAllExceptMfa',
effect: iam.Effect.DENY,
notActions: [
'iam:ChangePassword',
'iam:CreateVirtualMFADevice',
'iam:EnableMFADevice',
'iam:GetUser',
'iam:ListMFADevices',
'iam:ResyncMFADevice',
'sts:GetSessionToken',
],
resources: ['*'],
conditions: {
BoolIfExists: { 'aws:MultiFactorAuthPresent': 'false' },
},
}),
],
}),
});
}
}
Compliance mapping
CIS AWS Foundations v3.0.0
CIS Microsoft Azure Foundations v3.0.0
CIS GCP Foundation v4.0.0
CIS OCI Foundation v2.0.0
NIST SP 800-53 rev5
ISO/IEC 27001:2022
ISO/IEC 27017:2015
1.10
1.1.2
1.2
1.7
IA-2(1)
A.5.17; A.8.5
n/a
Log signals
CloudTrail ConsoleLogin where userIdentity.type = "IAMUser" and additionalEventData.MFAUsed = "No" — direct evidence of a console password authenticating without a second factor.
CloudTrail DeactivateMFADevice or DeleteVirtualMFADevice on an IAM user that still holds an active LoginProfile — covers an attacker (or a careless user) stripping the second factor.
Credential-report row where password_enabled = true and mfa_active = false for any user — periodic-snapshot complement to the streaming events.
Pair the CloudWatch Logs Insights query with a daily scheduled job that re-parses the IAM credential report and emits a counter metric for rows with password_enabled=true,mfa_active=false; alarm when the counter is non-zero.
Alert threshold
ConsoleLogin with MFAUsed = "No" for an IAM user — page on first occurrence; CIS 1.10 implies steady-state zero so the detection is binary.
Credential-report row count with password_enabled=true AND mfa_active=false > 0 — daily digest with severity proportional to the row count (0 = silent, 1-5 = ticket, 6+ = page).
Tune the streaming page rule by suppressing 5 minutes after a successful EnableMFADevice by the same user (allows the legitimate enrol-then-sign-in sequence to complete without flapping).
Initial response
Open the user's IAM record and confirm whether they are mid-enrolment for a new device (cross-reference the helpdesk queue for a pending MFA-replacement ticket).
If no enrolment ticket exists: revoke active sessions by detaching the deny-without-MFA policy temporarily, force a password reset via aws iam update-login-profile --password-reset-required, and require MFA enrolment on the next sign-in via the helpdesk-mediated path.
If a DeactivateMFADevice event preceded the sign-in: treat as a credential-takeover precursor and engage general/ir.html for a full session review across all the user's federated entitlements.
Any remaining long-lived IAM access keys must be rotated at most every 90 days. Severity is MEDIUM and the type is DETECTIVE rather than PREVENTIVE because rotation does not prevent compromise of an already-active key — an attacker who exfiltrates a fresh key has 90 days of unimpeded use — it bounds the window during which an undiscovered compromise remains usable, and the rotation cadence itself surfaces stale credentials whose owners have left (AWS IAM User Guide — security best practices (accessed 2026-05)). The strategic answer is to eliminate long-lived keys entirely via aws-iam-06-role-assumption; this control is the compensating audit for what remains.
Remediation — AWS CLI
# List keys older than 90 days across all users.
aws iam list-users --query 'Users[].UserName' --output text \
| tr '\t' '\n' \
| while read u; do
aws iam list-access-keys --user-name "$u" \
--query 'AccessKeyMetadata[?CreateDate<=`'"$(date -u -d '90 days ago' +%Y-%m-%dT%H:%M:%SZ)"'`].[UserName,AccessKeyId,CreateDate]' \
--output text
done
AWSTemplateFormatVersion: '2010-09-09'
Description: AWS Config managed rule flagging IAM access keys older than the rotation threshold.
Resources:
AccessKeyRotationRule:
Type: AWS::Config::ConfigRule
Properties:
ConfigRuleName: access-keys-rotated-90d
Description: Flags IAM access keys not rotated within maxAccessKeyAge days.
Source:
Owner: AWS
SourceIdentifier: ACCESS_KEYS_ROTATED
InputParameters: !Sub '{"maxAccessKeyAge":"90"}'
Compliance mapping
CIS AWS Foundations v3.0.0
CIS Microsoft Azure Foundations v3.0.0
CIS GCP Foundation v4.0.0
CIS OCI Foundation v2.0.0
NIST SP 800-53 rev5
ISO/IEC 27001:2022
ISO/IEC 27017:2015
1.14
1.1.6
1.4
1.14
IA-5(1)
A.5.17; A.8.2
n/a
Log signals
AWS Config rule ACCESS_KEYS_ROTATED emits a NON_COMPLIANT evaluation against any IAM-user resource whose attached access-key CreateDate is older than the configured 90-day threshold.
IAM credential-report rows where access_key_1_last_rotated (or _2_) is more than 90 days behind the report timestamp — periodic-snapshot complement.
Last-used recency gap: access-key whose access_key_1_last_used_date trails access_key_1_last_rotated by more than 30 days while still active — flags dormant keys whose owners likely no longer exist.
Run the CloudWatch Logs Insights query against the Config-delivery log group; mirror the same recency gap inside a weekly Lambda that re-pulls get-credential-report and emits a CloudWatch metric per non-compliant user.
Alert threshold
Non-zero count of NON_COMPLIANT users on the weekly Config evaluation cadence — informational ticket per user with rotation-required acknowledgement deadline of 14 days.
Key age > 180 days (double the policy threshold) — escalate to page; treat the workload owner's silence as confirmation the credential is orphaned.
Behavioural anomaly band: a key whose 30-day moving-average call volume drops to zero while still active for > 60 days — strong dormancy signal independent of the rotation clock.
Initial response
Map the non-compliant access-key back to its workload via tag workload-id on the parent IAM user; consult the workload's owning team's on-call rotation rather than the original key creator (who may have left).
Pursue elimination first: replace the long-lived key with the role-assumption path from aws-iam-06-role-assumption; if elimination is impossible in the audit window, rotate via aws iam create-access-key + cutover + aws iam delete-access-key on the prior key.
Forward the residual long-lived-key inventory monthly to the cloud-platform steering group per general/ir.html as a recurring hygiene metric, not an incident.
Workload-to-AWS authentication should use IAM roles assumed via STS, not long-lived IAM-user access keys baked into application configuration. The role-assumption surface covers EC2 instance profiles, ECS task roles, Lambda execution roles, and (for Kubernetes workloads on EKS) EKS Pod Identity — the 2023-introduced and now-preferred mechanism — alongside IRSA (IAM Roles for Service Accounts), which remains supported and is the only option in some older clusters (AWS IAM User Guide — security best practices (accessed 2026-05)). Combined with aws-iam-05-key-rotation, eliminating long-lived keys reduces the credential-rotation problem to "rotate the role's trust policy when the trust relationship changes", which is rare.
Remediation — AWS CLI
# Create an EC2-assumable role with a workload-scoped policy attached.
aws iam create-role \
--role-name app-runtime-role \
--assume-role-policy-document '{
"Version":"2012-10-17",
"Statement":[{
"Effect":"Allow",
"Principal":{"Service":"ec2.amazonaws.com"},
"Action":"sts:AssumeRole"
}]
}'
aws iam attach-role-policy \
--role-name app-runtime-role \
--policy-arn arn:aws:iam::<ACCOUNT_ID>:policy/app-runtime-policy
# Attach via instance profile.
aws iam create-instance-profile --instance-profile-name app-runtime-profile
aws iam add-role-to-instance-profile \
--instance-profile-name app-runtime-profile \
--role-name app-runtime-role
Remediation — Terraform
# Terraform AWS provider ~> 5.0
resource "aws_iam_role" "app_runtime" {
name = "app-runtime-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_instance_profile" "app_runtime" {
name = "app-runtime-profile"
role = aws_iam_role.app_runtime.name
}
# For EKS, prefer Pod Identity (2023+) over IRSA:
resource "aws_eks_pod_identity_association" "app" {
cluster_name = var.eks_cluster_name
namespace = "default"
service_account = "app"
role_arn = aws_iam_role.app_runtime.arn
}
CloudTrail sts:AssumeRole events where userIdentity.type = "IAMUser" — long-lived IAM-user keys still acting as the on-ramp to role sessions; the desirable shape is FederatedUser or WebIdentity.
CloudTrail UpdateAssumeRolePolicy mutating an existing role's trust policy to widen Principal to an external account or to "*", especially without an sts:ExternalId condition.
EKS Pod Identity drift: eks:DescribePodIdentityAssociation revealing service-account-to-role bindings whose target role was not provisioned through the cluster's managed Terraform stack.
Query
fields @timestamp, eventName, userIdentity.arn, requestParameters.policyDocument, requestParameters.roleName
| filter eventName = "UpdateAssumeRolePolicy"
| filter requestParameters.policyDocument like /"Principal":\s*"\*"/ or requestParameters.policyDocument like /:root"/
| sort @timestamp desc
| limit 50
Run this CloudWatch Logs Insights query against the management-account trail and the per-member trails in parallel; the regex catches both wildcard principals and bare-account-root principals lacking an external-id constraint.
Alert threshold
Any UpdateAssumeRolePolicy widening the principal to "*" — page immediately; this is the canonical role-trust-policy backdoor shape.
Cross-account trust additions where the target account is not on the maintained partner-account allow-list — page within 15 minutes; the allow-list is the authoritative ledger and any deviation is actionable.
Behavioural variance: 7-day moving-average of distinct role-assumers exceeding the 90-day baseline by more than 3× — informational ticket; broad fan-out often precedes credential-stuffing-style enumeration.
Initial response
Snapshot the role's current trust policy via aws iam get-role --role-name <name> and diff against the version-controlled Terraform state to confirm whether the change was applied through the IaC path.
If out-of-band: revert by re-applying the Terraform stack (terraform apply -target=aws_iam_role.<name>), then run aws sts get-caller-identity from any currently-assumed session of the affected role to confirm the session was force-rotated.
Capture the originating principal's last 24h of CloudTrail activity into an evidence bundle and hand off to general/ir.html — trust-policy widening is rarely a misclick and the responder should treat the source principal as compromised pending review.
When delegating IAM-management rights to non-admin principals (the canonical case: a platform team gives application teams the ability to create roles for their own workloads), attach a permission boundary to every role created via the delegated path. A permission boundary is an IAM-managed policy that caps the maximum effective permissions of the principal it is attached to; even if the developer attaches AdministratorAccess to a new role, the role's effective permissions remain the intersection of the attached policies and the boundary (AWS IAM User Guide — security best practices (accessed 2026-05)).
Remediation — AWS CLI
# Apply a permission boundary to a delegated developer role.
aws iam put-role-permissions-boundary \
--role-name developer-role \
--permissions-boundary arn:aws:iam::<ACCOUNT_ID>:policy/dev-permission-boundary
# Require the boundary on any role the developer creates (use a condition in
# the developer's own policy — see Terraform below).
Remediation — Terraform
# Terraform AWS provider ~> 5.0
resource "aws_iam_policy" "dev_boundary" {
name = "dev-permission-boundary"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{ Effect = "Allow", Action = ["s3:*","dynamodb:*","logs:*"], Resource = "*" },
{ Effect = "Deny", Action = ["iam:*Organizations*","kms:PutKeyPolicy"], Resource = "*" }
]
})
}
resource "aws_iam_role" "developer" {
name = "developer-role"
permissions_boundary = aws_iam_policy.dev_boundary.arn
assume_role_policy = data.aws_iam_policy_document.dev_trust.json
}
# Force the boundary on any role the developer creates.
data "aws_iam_policy_document" "developer_inline" {
statement {
effect = "Allow"
actions = ["iam:CreateRole", "iam:AttachRolePolicy"]
resources = ["*"]
condition {
test = "StringEquals"
variable = "iam:PermissionsBoundary"
values = [aws_iam_policy.dev_boundary.arn]
}
}
}
CloudTrail CreateRole events whose requestParameters lack a permissionsBoundary attribute, originating from a delegated developer principal — the developer is creating an unbounded role.
CloudTrail PutRolePermissionsBoundary with a boundary ARN that does not match the canonical organisation-published list — covers an attacker substituting a permissive boundary for the intended one.
CloudTrail DeleteRolePermissionsBoundary on a role previously bounded — explicit removal of the cap and almost always a privilege-escalation precursor.
Query
fields @timestamp, eventName, userIdentity.arn, requestParameters.roleName, requestParameters.permissionsBoundary
| filter eventName in ["CreateRole","PutRolePermissionsBoundary","DeleteRolePermissionsBoundary"]
| filter eventName = "DeleteRolePermissionsBoundary"
or (eventName = "CreateRole" and isempty(requestParameters.permissionsBoundary))
| sort @timestamp desc
| limit 100
The CloudWatch Logs Insights query joins the three boundary-touching events into a single feed; the OR clause catches both the explicit-removal and silent-omission shapes that defeat the boundary control.
Alert threshold
Any DeleteRolePermissionsBoundary event — page immediately; there is no legitimate steady-state need to strip a boundary from a delegated role.
Three or more CreateRole events without a boundary from the same developer principal in a 1-hour window — informational ticket; isolated cases may be IaC stack-drift, the cluster is the signal.
Static allow-list deviation: the set of boundary ARNs in use must equal the published-policy ARN list — page on any unknown ARN appearing in PutRolePermissionsBoundary.
Initial response
Retrieve the affected role's current effective permissions via aws iam simulate-principal-policy; quantify how much the boundary-removal widened the attack surface before deciding on revert vs. quarantine.
Re-attach the canonical boundary via aws iam put-role-permissions-boundary --permissions-boundary <canonical-arn>; if the role's identity policy contains explicit iam:* or kms:PutKeyPolicy grants, treat the role itself as untrustworthy and rotate or delete it.
Track the originating developer's prior 7 days of IAM-modifying calls; if a pattern of policy widening emerges, escalate to general/ir.html as a credential-takeover hypothesis rather than a single-event misclick.
At the AWS Organizations layer, apply a Service Control Policy (SCP) deny-list that fences destructive actions (CloudTrail disable, KMS key deletion, Config recorder stop) and disallowed regions across every member account. SCPs are the only IAM construct that root cannot bypass: even the root user of a member account is bound by SCPs inherited from the management account (AWS Organizations User Guide — SCP examples (accessed 2026-05)).
Remediation — AWS CLI
# Create a deny-list SCP and attach it to the root or to a specific OU.
cat > deny-destructive.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyDestructiveAudit",
"Effect": "Deny",
"Action": [
"cloudtrail:StopLogging",
"cloudtrail:DeleteTrail",
"config:DeleteConfigurationRecorder",
"config:StopConfigurationRecorder",
"kms:ScheduleKeyDeletion",
"kms:DisableKey"
],
"Resource": "*"
},
{
"Sid": "DenyDisallowedRegions",
"Effect": "Deny",
"NotAction": ["iam:*","sts:*","organizations:*","cloudfront:*","route53:*","support:*"],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": ["eu-west-1","eu-central-1","us-east-1"]
}
}
}
]
}
EOF
aws organizations create-policy \
--type SERVICE_CONTROL_POLICY \
--name deny-destructive \
--description "Deny destructive audit actions + disallowed regions" \
--content file://deny-destructive.json
CloudTrail organizations:DetachPolicy, organizations:DisablePolicyType, or organizations:UpdatePolicy events targeting the deny-destructive SCP — direct tampering with the preventive guard itself.
CloudTrail errorCode = "AccessDenied" with errorMessage containing "explicit deny in a service control policy" against any of the fenced API names (e.g. cloudtrail:StopLogging, kms:ScheduleKeyDeletion) — the SCP firing in production confirms an attempted destructive action.
CloudTrail API calls whose requestParameters.region falls outside the allow-listed regions eu-west-1, eu-central-1, us-east-1 — resource-creation attempts in unmonitored regions even before the SCP denial materialises.
Query
fields @timestamp, eventName, userIdentity.arn, errorCode, errorMessage, awsRegion
| filter errorCode = "AccessDenied" and errorMessage like /service control policy/
| filter eventName in ["StopLogging","DeleteTrail","DeleteConfigurationRecorder","ScheduleKeyDeletion","DisableKey"]
| stats count() as denies by userIdentity.arn, eventName
| sort denies desc
The CloudWatch Logs Insights query aggregates SCP denials per principal so that an attacker walking down the fenced API list shows up as a single high-confidence cluster rather than scattered noise.
Alert threshold
Any successful (i.e. not denied) invocation of the fenced APIs from a non-management-account principal — page immediately; bypass is the worst-case signal and means the SCP has been silently detached.
Five or more SCP denials from the same principal across distinct fenced APIs in a 30-minute window — page; this is the walk-the-deny-list shape.
Any DetachPolicy event against the deny-destructive policy — page; legitimate detach is a coordinated change-control event whose ticket must be linked in the response within 1 hour.
Initial response
Confirm the deny-destructive SCP is still attached to the organisation root via aws organizations list-policies-for-target --target-id <root>; if missing, re-attach immediately via the version-controlled Terraform stack and force apply.
Quarantine the originating principal: detach all its identity-based policies leaving only an explicit deny-all, then revoke any active sessions via session-policy update; this halts further exploration without removing forensic evidence.
Open an organisation-wide incident per general/ir.html — SCP tampering implies elevated cross-account access and the responder treats the management-account audit log as the authoritative reconstruction source.
Enable IAM Access Analyzer in every region of every account, with both an account-level analyzer (zone-of-trust = the AWS account) and, in the delegated-administrator account, an organization-level analyzer (zone-of-trust = the AWS Organization). Access Analyzer continuously evaluates resource policies (S3 buckets, KMS keys, IAM roles, Lambda functions, Secrets Manager secrets, SQS queues, SNS topics, EBS snapshots, ECR repositories, EFS file systems, RDS snapshots) and reports findings whenever a resource is shared outside the zone of trust (IAM Access Analyzer documentation (accessed 2026-05)).
Remediation — AWS CLI
# Account-level analyzer.
aws accessanalyzer create-analyzer \
--analyzer-name account-analyzer \
--type ACCOUNT
# Organization-level analyzer (must be run in the delegated admin account).
aws accessanalyzer create-analyzer \
--analyzer-name org-analyzer \
--type ORGANIZATION
Remediation — Terraform
# Terraform AWS provider ~> 5.0
resource "aws_accessanalyzer_analyzer" "account" {
analyzer_name = "account-analyzer"
type = "ACCOUNT"
}
resource "aws_accessanalyzer_analyzer" "organization" {
analyzer_name = "org-analyzer"
type = "ORGANIZATION"
# Must be applied in the Organizations delegated-administrator account.
}
EventBridge events on bus default with source aws.access-analyzer and detail-type Access Analyzer Finding — every new external-principal finding emits exactly one such event in real time.
Access Analyzer findings whose condition.aws:PrincipalAccount resolves outside the organisation's account-id inventory — the cross-account exposure shape; whose principal.AWS equals "*" — the public-exposure shape.
CloudTrail access-analyzer:UpdateFindings with status = "ARCHIVED" on findings not previously approved through the change-management ledger — covers an attacker silencing the analyzer by archiving their own exposure.
Substitute the organisation's account-id list into the query at deploy time via the same CloudWatch Logs Insights parameter mechanism used by the Config aggregator; the parameter is sourced from aws organizations list-accounts on a daily refresh.
Alert threshold
Public-exposure finding (principal.AWS = "*") on S3, KMS, Lambda, ECR, Secrets Manager, RDS-snapshot, EBS-snapshot, EFS, SQS, SNS — page within 15 minutes regardless of resource value; the cost of paging is dwarfed by the cost of even a 1-hour public window.
Cross-account finding to an account outside the partner-account allow-list — page within 1 hour; the allow-list is the authoritative ledger.
Manual archive of an unresolved finding via UpdateFindings without a referenced change-management ticket ID in the resourceOwnerAccount note field — informational ticket for SOC-lead review.
Initial response
Open the affected resource in the AWS console and confirm whether the exposure is an intended customer-integration surface; document the business owner and intended consumer before any policy revert.
Disposition the finding: if intended, archive with the change-management ticket ID embedded in the archive note; if unintended, edit the resource policy to remove the offending statement and confirm the finding closes within the analyzer's 30-minute re-evaluation cycle.
For unintended public-exposure findings, follow general/ir.html data-exposure protocol: pull CloudTrail access patterns for the resource over the exposure window and assess data-egress volume before declaring the incident closed.
Generate and parse the IAM credential report on a scheduled cadence (daily or weekly) to identify IAM users whose passwords or access keys have not been used in ≥45 days; disable those credentials and reclaim unused users. CIS AWS Foundations v3.0.0 controls 1.12 and 1.13 codify the 45-day threshold. The credential report is a CSV emitted by IAM that lists, for every user, password and access-key activity timestamps — the canonical input for periodic IAM hygiene reviews (CIS Amazon Web Services Foundations Benchmark v3.0.0 — Jan 2024 release (accessed 2026-05)).
Remediation — AWS CLI
# Generate and download the credential report.
aws iam generate-credential-report
aws iam get-credential-report \
--query 'Content' --output text \
| base64 -d > credential-report.csv
# Find users whose access key has not been used in >=45 days.
awk -F, 'NR>1 && $11!="N/A" && $11!="" {
cmd = "date -u -d \"" $11 "\" +%s";
cmd | getline ts; close(cmd);
now = systime();
if (now - ts > 45*86400) print $1 "\t" $11
}' credential-report.csv
Remediation — Terraform
# Terraform AWS provider ~> 5.0
# Scheduled EventBridge rule + Lambda skeleton that pulls the credential report
# and emits SNS alerts for credentials unused >=45 days. Lambda source code is
# referenced as a deployment package; secrets are NOT embedded.
resource "aws_cloudwatch_event_rule" "credential_audit" {
name = "credential-report-audit"
schedule_expression = "rate(7 days)"
}
resource "aws_lambda_function" "credential_audit" {
function_name = "credential-report-audit"
role = aws_iam_role.credential_audit.arn
handler = "index.handler"
runtime = "python3.12"
filename = var.deployment_package_path
environment {
variables = {
SNS_TOPIC_ARN = aws_sns_topic.iam_alerts.arn
STALE_DAYS = "45"
}
}
}
resource "aws_cloudwatch_event_target" "credential_audit" {
rule = aws_cloudwatch_event_rule.credential_audit.name
arn = aws_lambda_function.credential_audit.arn
}
Remediation — CloudFormation
AWSTemplateFormatVersion: '2010-09-09'
Description: AWS Config managed rule flagging IAM users whose credentials are unused beyond the threshold.
Resources:
IamUserUnusedCredentialsCheck:
Type: AWS::Config::ConfigRule
Properties:
ConfigRuleName: iam-user-unused-credentials-check
Description: Flags IAM users whose passwords or access keys are unused for >= maxCredentialUsageAge days.
Source:
Owner: AWS
SourceIdentifier: IAM_USER_UNUSED_CREDENTIALS_CHECK
InputParameters: !Sub '{"maxCredentialUsageAge":"90"}'
Compliance mapping
CIS AWS Foundations v3.0.0
CIS Microsoft Azure Foundations v3.0.0
CIS GCP Foundation v4.0.0
CIS OCI Foundation v2.0.0
NIST SP 800-53 rev5
ISO/IEC 27001:2022
ISO/IEC 27017:2015
1.12; 1.13
1.1.8
1.6
best-practices
AC-2(3); AC-2(4)
A.5.16; A.8.2
n/a
Log signals
Scheduled Lambda (the one provisioned by this control) emitting CloudWatch custom metric iam/CredentialReport/StaleUsers with value > 0 — the periodic-job's primary output.
CloudTrail GenerateCredentialReport calls failing with errorCode set (Throttling, ServiceFailure) on the audit cadence — the job cannot produce its evidence; absence of data is the signal.
IAM credential-report row deltas where password_last_used or access_key_1_last_used_date remains N/A for > 45 days while the corresponding credential remains active — the dormancy shape codified by CIS 1.12/1.13.
Query
fields @timestamp, @message
| filter @logStream like /credential-report-audit/
| filter @message like /STALE_USER/
| parse @message /STALE_USER user=(?<user>\S+) days_idle=(?<days>\d+)/
| stats count() as occurrences, max(days) as max_idle by user
| sort max_idle desc
The CloudWatch Logs Insights query parses structured log lines the audit Lambda emits per stale credential; pair it with a metric-filter that publishes the count of STALE_USER markers per run for SLO dashboards.
Alert threshold
Custom metric StaleUsers > 0 for two consecutive runs — ticket per stale user with remediation deadline of 7 days from notice; one isolated run may indicate a freshly-onboarded user yet to authenticate.
Custom metric > 10 in any single run — page; the cluster suggests a missed offboarding wave or a long-paused decommission project.
Audit Lambda missing its scheduled invocation for > 14 days (no GenerateCredentialReport entry in CloudTrail attributed to the Lambda's role) — page; the control's own observability is the precondition for everything else.
Initial response
Confirm the stale user is not a service account pending migration to a role; cross-reference against the IaC workload-inventory tag migration-cohort before disabling anything.
Disable the dormant credential branches: aws iam delete-login-profile for password rows, aws iam update-access-key --status Inactive for key rows; the inactive state preserves forensic linkage while removing usability.
If the audit job itself stopped emitting metrics, treat the gap as the incident: restore the EventBridge schedule, replay the missed runs by manually invoking the Lambda, and report the coverage-gap window to general/ir.html as a control-failure event.