v0.3.0 AI Drafted

AWS IAM Identity Center Hardening Guide

Identity Last updated: 2026-09-25

AWS identity management hardening for IAM Identity Center including MFA, permission sets, and account access

View:

Overview

AWS IAM Identity Center (formerly AWS SSO) is the recommended service for managing workforce access to AWS accounts and applications. As the central identity service for AWS Organizations, IAM Identity Center security configurations directly impact cloud access security.

Intended Audience

  • Security engineers managing AWS access
  • Cloud administrators configuring IAM Identity Center
  • Platform engineers managing AWS Organizations
  • GRC professionals assessing cloud identity

How to Use This Guide

  • L1 (Crawl): Essential controls for all organizations
  • L2 (Walk): Enhanced controls for security-sensitive environments
  • L3 (Run): Strictest controls for regulated industries

Scope

This guide covers AWS IAM Identity Center security including MFA enforcement, permission sets, identity sources, and session policies.


Table of Contents

  1. Authentication & MFA
  2. Identity Source Configuration
  3. Permission Management
  4. Monitoring & Compliance
  5. Compliance Quick Reference

1. Authentication & MFA

1.1 Enforce Multi-Factor Authentication

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 6.5
NIST 800-53 IA-2(1)

Description

Require MFA for all IAM Identity Center users — enforced natively when the identity source is the Identity Center directory, AWS Managed Microsoft AD, or AD Connector, and enforced at the external IdP when one is connected.

Correction (2026-08-08) — where MFA is actually enforced. AWS documents that IAM Identity Center’s native MFA configuration is “currently not supported for external identity providers.” If your identity source is an external IdP (see 2.1), the MFA settings described below do not apply to those users and MFA must be enforced in the IdP itself. Earlier revisions of this guide recommended both native MFA enforcement (1.1) and an external IdP (2.1) as L1 controls without noting that the first is inert under the second — implement whichever matches your identity source, and treat 2.1 as carrying the MFA obligation when an external IdP is in use. Sources: Enable MFA · Configure MFA

Rationale

Why This Matters:

  • MFA adds a second factor beyond the password, so a phished or stolen credential alone cannot reach the AWS access portal
  • IAM Identity Center is the single front door to every account in the organization — one MFA-less login compromise can expose the entire AWS estate
  • FIDO2 security keys and authenticator apps resist phishing and SIM-swap attacks that defeat SMS-delivered codes
  • Enforcing MFA on every sign-in closes the gap where long-lived sessions skip re-verification

Attack Prevented: Credential theft, phishing, credential stuffing, MFA bypass, account takeover

Prerequisites

  • IAM Identity Center enabled in management account
  • AWS Organizations configured
  • Admin access to IAM Identity Center

ClickOps Implementation

Step 0: Determine Which Path Applies

  1. Navigate to: IAM Identity Center → Settings → Identity source
  2. If the source is Identity Center directory, AWS Managed Microsoft AD, or AD Connector, continue with Steps 1-3 below
  3. If the source is an external identity provider, stop here — configure MFA in that IdP instead, and record the IdP’s enforcement policy as the evidence for this control

Step 1: Open the MFA Configuration

  1. Navigate to: IAM Identity Center → Settings → Authentication tab → Multi-factor authentication → Configure
  2. Note that MFA is on by default for directory users, so the task is usually verification and tightening rather than initial enablement

Step 2: Configure Prompting and Enforcement

  1. Under Prompt users for MFA, select Every time they sign in (always-on) — the default and the most secure mode. Only when their sign-in context changes (context-aware) lets a user mark a device as trusted and skip MFA on it; Never (disabled) turns MFA off
  2. Under If a user does not yet have a registered MFA device, select Require them to register an MFA device at sign in or Block their sign-in. Avoid Require them to provide a one-time password sent by email to sign in — AWS notes it does not meet the bar for industry-standard MFA — and Allow them to sign in
  3. Keep always-on prompting if every sign-in must be challenged: under context-aware prompting, a user who ticks This is a trusted device is not prompted even when Block their sign-in is selected
  4. Choose Save changes

Step 3: Choose MFA Types

  1. Under Users can authenticate with these MFA types, enable Security keys and built-in authenticators (FIDO2, phishing-resistant — recommended)
  2. Enable Authenticator apps (TOTP) only if users need a fallback to a security key
  3. IAM Identity Center offers no SMS option and no hardware-TOTP option, so there is nothing to disable
  4. Note the per-user registration ceiling: a user may register a maximum of 8 MFA devices — up to 2 authenticator apps and 6 FIDO authenticators

Sources: Prompt users for MFA · Configure MFA device enforcement · Available MFA types

Time to Complete: ~30 minutes

Automation: ClickOps only — IAM Identity Center exposes no public API for MFA settings (IAM Identity Center API operations, 2026-09-24). Changes are still observable: the 4.1 Sigma rule watches PutMfaDeviceManagementForDirectory (the console action that writes the directory’s MFA device-management settings) and MFA device registration and removal.


1.2 Configure Session Duration

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 6.2
NIST 800-53 AC-12

Description

Configure session duration limits across the three distinct session types IAM Identity Center maintains — interactive (portal) sessions, permission-set role sessions, and application sessions — recognizing that they expire independently of each other.

Rationale

Why This Matters:

  • Shorter sessions shrink the window in which a hijacked token or unattended session can be abused
  • Long-lived portal and permission-set sessions let a stolen session token stay valid long after a user leaves, undermining deprovisioning
  • Tighter durations on privileged permission sets force frequent re-authentication for the highest-blast-radius access
  • The three session types are independent — revoking one does not terminate the others, so a single “disable the user” action does not immediately cut access

Attack Prevented: Session hijacking, token replay, unattended-workstation abuse, standing access after offboarding

The Three Session Types

Session type Documented maximum Behavior on user revocation
Interactive (AWS access portal) session Up to 90 days Ends when the interactive session is terminated
Permission-set role session (AWS account access) Up to 12 hours Not affected — revoking, disabling, or deleting the user does not end an existing account session
Application session Refreshed automatically about every 1 hour; ends roughly 30 minutes after the interactive session ends Ends shortly after the interactive session

The 12-hour exposure window. AWS documents that removing a user’s access — including disabling or deleting the user — does not terminate permission-set role sessions already in flight. Those sessions run to their configured duration, which can be as long as 12 hours. Offboarding and incident response therefore require an explicit step beyond user deletion: shorten permission-set session durations in advance, and on suspected compromise add the user to a pre-staged Deny policy keyed on identitystore:userId. Ending the user’s active sessions is not enough on its own — AWS states that it “doesn’t end any active IAM role sessions in the AWS Management Console or AWS CLI.” Sources: Use a Deny policy to revoke active user permissions · View and end active sessions

Kiro and long interactive sessions. AWS documents that Kiro sessions extend the interactive session duration to 90 days, and that depending on when your organization enabled the relevant capability this may be enabled by default. Verify the current interactive session setting rather than assuming a short default.

No SAML Single Logout. IAM Identity Center supports SAML Single Logout in neither direction — signing out of the external IdP does not sign the user out of the AWS access portal, and signing out of the portal does not sign them out of the IdP. Do not treat IdP logout as a session-termination control.

Source: IAM Identity Center — Authentication concepts

ClickOps Implementation

Step 1: Configure the Interactive (Portal) Session

  1. Navigate to: Settings → Authentication tab → Session duration → Configure
  2. Under User interactive sessions, read the current value before changing it — the default is 8 hours, the allowed range is 15 minutes to 90 days, and it may already be set to a long duration
  3. Reduce it to the shortest duration your workforce can tolerate; a 90-day interactive session is rarely defensible outside of specific tooling requirements

Step 2: Configure Permission Set Sessions

  1. Edit each permission set
  2. Set session duration — the range is 1 to 12 hours
  3. Set administrative and other high-blast-radius permission sets to 1 hour — the minimum AWS allows — since these sessions survive user deletion

Step 3: Document the Revocation Procedure

  1. Record that user deletion alone leaves account sessions live for up to their configured duration
  2. Pre-stage a Deny policy keyed on identitystore:userId in each permission set (or as an SCP), and on offboarding or suspected compromise add the user’s ID to it — this is the step that cuts off in-flight account sessions. Ending the user’s sessions under Users → user → Active sessions → End sessions stops the portal session only
  3. Test the procedure so responders are not discovering the gap during an incident

Code Implementation

Code Pack: CLI Script
hth-aws-iam-identity-center-1.02-audit-permission-set-session-duration.sh View source on GitHub ↗
# Compare every permission set's SessionDuration with the guide's thresholds.
PS_JSON=$(sso_admin list-permission-sets) || {
  fail "1.2 Failed to list permission sets (sso:ListPermissionSets)"
  increment_failed
  summary
}
# Extract the list on its own line: inside a for-list a jq failure is silent and
# would read as "no permission sets" (exit 0) instead of a failed read.
PS_ARNS=$(echo "${PS_JSON}" | jq -r '.PermissionSets[]') || {
  fail "1.2 ListPermissionSets returned no readable PermissionSets list -- nothing was audited"
  increment_failed
  summary
}
PRIV_RE="$(echo "${PRIVILEGED}" | tr ' ' '|')"
PS_COUNT=0; PRIV_COUNT=0; STD_COUNT=0; PRIV_OVER=0; STD_OVER=0; READ_ERRORS=0

for PS_ARN in ${PS_ARNS}; do
  PS_DETAIL=$(sso_admin describe-permission-set --permission-set-arn "${PS_ARN}") || {
    fail "1.2 Cannot describe ${PS_ARN##*/} (sso:DescribePermissionSet)"
    READ_ERRORS=$((READ_ERRORS + 1)); continue
  }
  PS_NAME=$(echo "${PS_DETAIL}" | jq -r '.PermissionSet.Name')
  DURATION=$(echo "${PS_DETAIL}" | jq -r '.PermissionSet.SessionDuration // "PT1H"')
  SECONDS_SET=$(iso8601_seconds "${DURATION}") || {
    fail "1.2 '${PS_NAME}': unparseable session duration '${DURATION}'"
    READ_ERRORS=$((READ_ERRORS + 1)); continue
  }
  POLICIES=$(sso_admin list-managed-policies-in-permission-set --permission-set-arn "${PS_ARN}") || {
    fail "1.2 Cannot list managed policies on '${PS_NAME}' (sso:ListManagedPoliciesInPermissionSet)"
    READ_ERRORS=$((READ_ERRORS + 1)); continue
  }
  PS_COUNT=$((PS_COUNT + 1))

  if echo "${POLICIES}" | jq -e --arg re ":iam::aws:policy/(${PRIV_RE})$" \
      '[(.AttachedManagedPolicies // [])[].Arn | select(test($re))] | length > 0' >/dev/null; then
    LIMIT_S="${ADMIN_MAX_S}"; LIMIT="${ADMIN_MAX}"; KIND="privileged"; PRIV_COUNT=$((PRIV_COUNT + 1))
  else
    LIMIT_S="${OTHER_MAX_S}"; LIMIT="${OTHER_MAX}"; KIND="standard"; STD_COUNT=$((STD_COUNT + 1))
  fi
  if [ "${SECONDS_SET}" -gt "${LIMIT_S}" ] && [ "${KIND}" = "privileged" ]; then
    fail "1.2 privileged permission set '${PS_NAME}': session ${DURATION} exceeds ${LIMIT}"
    PRIV_OVER=$((PRIV_OVER + 1))
  elif [ "${SECONDS_SET}" -gt "${LIMIT_S}" ]; then
    warn "1.2 standard permission set '${PS_NAME}': session ${DURATION} exceeds the ${LIMIT} advisory"
    STD_OVER=$((STD_OVER + 1))
  else
    info "1.2   ${KIND} permission set '${PS_NAME}': session ${DURATION} (limit ${LIMIT})"
  fi
done

Automation: Permission-set session duration is manageable as code — aws sso-admin update-permission-set --session-duration or aws_ssoadmin_permission_set.session_duration (see the 3.1 and 3.3 Terraform packs); the pack above audits it. The interactive (portal) session duration is ClickOps only — IAM Identity Center exposes no public API for it (IAM Identity Center API operations, 2026-09-24).


1.3 Configure Attribute-Based Access Control

Profile Level: L2 (Walk)

Framework Control
CIS Controls 6.8
NIST 800-53 AC-3

Description

Enable ABAC for fine-grained access control.

Rationale

Why This Matters:

  • ABAC scopes access by matching user and session attributes (department, project, environment) to resource tags, enforcing least privilege dynamically
  • Tag-based conditions stop users from reaching resources outside their team or environment even when they hold a broad permission set
  • Attribute-driven policies scale without spawning an ever-growing set of bespoke permission sets per team or project

Attack Prevented: Lateral movement, over-broad access, privilege creep, cross-environment resource access

ClickOps Implementation

Step 1: Enable ABAC

  1. Navigate to: Settings → in the Attributes for access control information box, choose Enable
  2. Open the Attributes for access control tab → Manage attributes → Add attribute
  3. Enter a Key (the session-tag name your policies reference) and a Value that points at the identity-source attribute, such as ${path:enterprise.department}. With an external IdP, attributes can arrive in the SAML assertion instead

Step 2: Use in Permission Sets

  1. Create ABAC-aware policies
  2. Reference user attributes
  3. Implement tag-based access

Code Implementation

Code Pack: Terraform
hth-aws-iam-identity-center-1.03-configure-abac.tf View source on GitHub ↗
# Enable ABAC attributes for Identity Center.
# Map Identity Center directory attributes to session tags. Sources use AWS's
# ${path:...} syntax, escaped as $${...} so Terraform passes it through literally.
# AWS exposes enterprise.{department,costCenter,division,organization,...} but no
# "project" attribute -- pass project or environment tags in the SAML assertion
# (https://aws.amazon.com/SAML/Attributes/AccessControl:<Key>) instead.
resource "aws_ssoadmin_instance_access_control_attributes" "abac" {
  instance_arn = local.sso_instance_arn

  attribute {
    key = "Department"
    value {
      source = ["$${path:enterprise.department}"]
    }
  }

  attribute {
    key = "CostCenter"
    value {
      source = ["$${path:enterprise.costCenter}"]
    }
  }

  attribute {
    key = "Division"
    value {
      source = ["$${path:enterprise.division}"]
    }
  }
}

# Example permission set using ABAC for department-scoped access
resource "aws_ssoadmin_permission_set" "department_scoped" {
  instance_arn     = local.sso_instance_arn
  name             = "DepartmentScopedAccess"
  description      = "ABAC-enabled permission set scoped by department tag"
  session_duration = "PT4H"

  tags = {
    ManagedBy = "how-to-harden"
    Control   = "1.3-configure-abac"
  }
}

# Inline policy enforcing ABAC tag matching. Each S3 action uses the tag
# condition key that action actually supports.
resource "aws_ssoadmin_permission_set_inline_policy" "department_abac_policy" {
  instance_arn       = local.sso_instance_arn
  permission_set_arn = aws_ssoadmin_permission_set.department_scoped.arn

  inline_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid      = "ReadObjectsTaggedForMyDepartment"
        Effect   = "Allow"
        Action   = ["s3:GetObject"]
        Resource = "*"
        Condition = {
          StringEquals = {
            "s3:ExistingObjectTag/Department" = "$${aws:PrincipalTag/Department}"
          }
        }
      },
      {
        Sid      = "WriteObjectsTaggedForMyDepartment"
        Effect   = "Allow"
        Action   = ["s3:PutObject"]
        Resource = "*"
        Condition = {
          StringEquals = {
            "s3:RequestObjectTag/Department" = "$${aws:PrincipalTag/Department}"
          }
        }
      },
      {
        Sid      = "ListBucketsTaggedForMyDepartment"
        Effect   = "Allow"
        Action   = ["s3:ListBucket"]
        Resource = "*"
        Condition = {
          StringEquals = {
            "s3:BucketTag/Department" = "$${aws:PrincipalTag/Department}"
          }
        }
      }
    ]
  })
}

2. Identity Source Configuration

2.1 Configure External Identity Provider

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 6.3, 12.5
NIST 800-53 IA-2, IA-8

Description

Connect to external IdP for centralized identity.

Rationale

Why This Matters:

  • Centralizing authentication in your corporate IdP enforces MFA, conditional access, and password policy consistently on every AWS login
  • The built-in Identity Center directory lacks the risk-based and conditional-access controls a dedicated IdP provides
  • A single source of truth means offboarding in the IdP immediately cuts AWS access, eliminating orphaned local accounts
  • SAML federation removes locally stored AWS credentials that are a prime target for theft

Attack Prevented: Credential theft, orphaned-account access, inconsistent MFA enforcement, identity sprawl

This control carries the MFA obligation. IAM Identity Center’s native MFA configuration is not supported for external identity providers (enable-mfa). Once you connect an external IdP, the settings in 1.1 no longer govern those users — MFA, conditional access, and session policy must all be enforced in the IdP, and that is what an assessor should be shown as evidence.

ClickOps Implementation

Step 1: Change Identity Source

  1. Navigate to: Settings → Identity source tab
  2. Click Actions → Change identity source
  3. Select external identity provider

Step 2: Configure SAML/SCIM

  1. Configure SAML 2.0 settings
  2. Enable automatic provisioning (SCIM)
  3. Configure attribute mappings

Step 3: Test and Migrate

  1. Test authentication
  2. Migrate users from Identity Center directory
  3. Verify access preserved

Automation: ClickOps only — IAM Identity Center has no public API to change the identity source or configure SAML federation (IAM Identity Center API operations, 2026-09-24). Changes are still observable: the 4.1 Sigma rule alerts on the sso-directory.amazonaws.com external-IdP configuration events.


2.2 Configure Automatic Provisioning

Profile Level: L2 (Walk)

Framework Control
CIS Controls 5.3
NIST 800-53 AC-2

Description

Enable SCIM for automatic user provisioning.

Rationale

Why This Matters:

  • SCIM automatically deprovisions departed users and updates group membership, eliminating standing access from stale accounts
  • Manual provisioning drifts over time, leaving orphaned users and incorrect group assignments that widen the attack surface
  • Group sync keeps permission-set assignments aligned with IdP and HR reality, enforcing least privilege as roles change

Attack Prevented: Orphaned-account access, privilege creep, offboarding gaps, unauthorized standing access

ClickOps Implementation

Step 1: Enable SCIM

  1. Navigate to: Settings → Identity source
  2. Enable automatic provisioning
  3. Generate SCIM endpoint and token

Step 2: Configure IdP

  1. Configure SCIM in identity provider
  2. Map user attributes
  3. Enable group sync

Automation: ClickOps only — enabling automatic provisioning and issuing the SCIM access token is console-only; the public API has no SCIM-enablement operation (Automatic provisioning, 2026-09-24).


2.3 Restrict Account Instances of IAM Identity Center

Profile Level: L2 (Walk)

Framework Control
CIS Controls 4.1, 6.8
NIST 800-53 CM-7, AC-3

Description

Prevent member accounts in your AWS Organization from standing up their own account instances of IAM Identity Center, which would create identity sources outside the organization instance your security team governs.

Changed default. AWS documents that for organizations that enabled IAM Identity Center after 2023-11-15, the ability for member accounts to create their own account instances is enabled by default. Organizations that enabled Identity Center before that date have it disabled by default. Check which side of that line you are on rather than assuming. Source: Enable IAM Identity Center

Changed enablement options (August 2026). New IAM Identity Center enablement now offers Single-Region, Multi-Region, and Custom configurations. Two consequences worth deciding deliberately rather than accepting:

  • Choosing Multi-Region automatically creates a multi-Region customer managed KMS key, and AWS KMS charges apply — this interacts directly with 4.3, including its lockout risk
  • Multi-account permissions is now an optional capability that is enabled by default, and the choice is made at enablement: if the deployment will use IAM Identity Center only for application authentication and never for AWS account access, turn off Enable multi-account permissions when creating the instance. It cannot be turned off afterwards — the UpdateInstance API accepts only PermissionSetsEnabled: true (“After permission sets are enabled, they cannot be disabled”)

Sources: Enable IAM Identity Center · IAM Identity Center document history

Rationale

Why This Matters:

  • An account instance created in a member account is a separate identity source with its own users, assignments, and applications — invisible to the controls applied to the organization instance
  • A team that stands up its own instance can grant application access on identities the central security team never provisioned, deprovisions, or reviews (a shadow-IdP problem, not merely a duplicate-tooling problem)
  • Because this is enabled by default for organizations onboarded after 2023-11-15, the exposure is usually inherited rather than deliberately chosen
  • Centralizing on the organization instance is what makes MFA policy, session policy, and access reviews actually complete

Attack Prevented: Shadow identity provider creation, ungoverned application access grants, identity sprawl outside central review, bypass of organization-wide access policy

Prerequisites

  • AWS Organizations with all features enabled
  • Service control policy (SCP) authoring permissions in the management account
  • An inventory of any existing account instances

ClickOps Implementation

Step 1: Inventory Existing Account Instances

  1. Determine whether any member account has already created an account instance of IAM Identity Center
  2. For each one found, identify its owner, its identity source, and the applications assigned through it
  3. Plan migration of any legitimate use onto the organization instance before restricting creation

Step 2: Restrict Creation via Service Control Policy

  1. In the management account, open IAM Identity Center → Dashboard → Central management → Prevent account instances, copy the SCP AWS provides, and choose Go to SCP dashboard — or navigate directly to: AWS Organizations → Policies → Service control policies
  2. Create or edit an SCP that denies sso:CreateInstance for member accounts (AWS’s example allow-lists specific accounts with a StringNotEquals condition on aws:PrincipalAccount)
  3. Attach the SCP to the organizational units that should never host their own instance (in most estates, all of them)

Step 3: Confirm the Restriction

  1. From a member account, attempt to create an account instance and confirm it is denied
  2. Record the SCP as the compensating control in your access-management documentation

Source: Use Service Control Policies to control account instance creation

Code Implementation

Code Pack: Terraform
hth-aws-iam-identity-center-2.03-restrict-account-instances.tf View source on GitHub ↗
# Deny sso:CreateInstance in member accounts; optionally allow-list accounts
# that are permitted to run their own account instance.
data "aws_iam_policy_document" "deny_account_instances" {
  statement {
    sid       = "DenyMemberAccountInstances"
    effect    = "Deny"
    actions   = ["sso:CreateInstance"]
    resources = ["*"]

    dynamic "condition" {
      for_each = length(var.allowed_account_ids) > 0 ? [1] : []
      content {
        test     = "StringNotEquals"
        variable = "aws:PrincipalAccount"
        values   = var.allowed_account_ids
      }
    }
  }
}

resource "aws_organizations_policy" "deny_account_instances" {
  name        = "hth-deny-identity-center-account-instances"
  description = "Deny IAM Identity Center account-instance creation in member accounts (HTH 2.3)"
  type        = "SERVICE_CONTROL_POLICY"
  content     = data.aws_iam_policy_document.deny_account_instances.json

  tags = {
    ManagedBy = "how-to-harden"
    Control   = "2.3-restrict-account-instances"
  }
}

# Attach to the organization root (r-...) or to each OU / account that must
# never host its own instance -- in most estates, the root.
resource "aws_organizations_policy_attachment" "deny_account_instances" {
  for_each = toset(var.target_ids)

  policy_id = aws_organizations_policy.deny_account_instances.id
  target_id = each.value
}
Code Pack: CLI Script
hth-aws-iam-identity-center-2.03-audit-account-instance-scp.sh View source on GitHub ↗
# 1. SCPs must be enabled on the organization root.
ROOTS=$(aws_json organizations list-roots) || {
  fail "2.3 Cannot list organization roots (organizations:ListRoots) -- run from the management account"
  increment_failed
  summary
}
SCP_STATUS=$(echo "${ROOTS}" | jq -r '[(.Roots // [])[].PolicyTypes[]?
  | select(.Type == "SERVICE_CONTROL_POLICY") | .Status][0] // "DISABLED"')
if [ "${SCP_STATUS}" != "ENABLED" ]; then
  fail "2.3 Service control policies are ${SCP_STATUS} on the root -- no SCP can restrict account instances"
  increment_failed
  summary
fi

# 2. Find SCPs whose Deny statements cover sso:CreateInstance, and where they attach.
POLICIES=$(aws_json organizations list-policies --filter SERVICE_CONTROL_POLICY) || {
  fail "2.3 Cannot list SCPs (organizations:ListPolicies)"
  increment_failed
  summary
}
DENYING=0; ATTACHED=0; READ_ERRORS=0; NOTACTION=0
for POLICY_ID in $(echo "${POLICIES}" | jq -r '(.Policies // [])[].Id'); do
  DOC=$(aws_json organizations describe-policy --policy-id "${POLICY_ID}") || {
    fail "2.3 Cannot read SCP ${POLICY_ID} (organizations:DescribePolicy)"
    READ_ERRORS=$((READ_ERRORS + 1)); continue
  }
  NAME=$(echo "${DOC}" | jq -r '.Policy.PolicySummary.Name')
  VERDICT=$(echo "${DOC}" | jq -r '
    def arr: if type == "array" then . else [.] end;
    def glob: "^" + (gsub("\\*"; ".*") | gsub("\\?"; ".")) + "$";
    (.Policy.Content | fromjson | .Statement | arr | map(select(.Effect == "Deny"))) as $deny
    | if any($deny[]; (.Action // [] | arr | any(.[]; . as $p | "sso:CreateInstance" | test($p | glob; "i"))))
      then "deny"
      elif any($deny[]; has("NotAction")) then "notaction"
      else "none" end')
  case "${VERDICT}" in
    deny)
      DENYING=$((DENYING + 1))
      TARGETS=$(aws_json organizations list-targets-for-policy --policy-id "${POLICY_ID}") || {
        fail "2.3 Cannot list targets of SCP '${NAME}' (organizations:ListTargetsForPolicy)"
        READ_ERRORS=$((READ_ERRORS + 1)); continue
      }
      TARGET_SUMMARY=$(echo "${TARGETS}" | jq -r '(.Targets // []) | group_by(.Type)
        | map("\(length) \(.[0].Type)") | join(", ")')
      if [ -n "${TARGET_SUMMARY}" ]; then
        pass "2.3 SCP '${NAME}' denies sso:CreateInstance and is attached to: ${TARGET_SUMMARY}"
        ATTACHED=$((ATTACHED + 1))
      else
        warn "2.3 SCP '${NAME}' denies sso:CreateInstance but is attached to nothing"
      fi ;;
    notaction)
      NOTACTION=$((NOTACTION + 1))
      warn "2.3 SCP '${NAME}' uses NotAction in a Deny -- review manually for sso:CreateInstance" ;;
  esac
done
# Report whether multi-account permissions are enabled on the organization instance.
# UpdateInstance accepts only PermissionSetsEnabled=true: once on, it cannot be turned off.
INSTANCE=$(sso_admin describe-instance) || {
  fail "2.3 Cannot describe the IAM Identity Center instance (sso:DescribeInstance)"
  increment_failed
  summary
}
info "2.3 Multi-account permissions (PermissionSetsEnabled): $(echo "${INSTANCE}" \
  | jq -r 'if has("PermissionSetsEnabled") then (.PermissionSetsEnabled | tostring) else "not reported" end')"

Validation & Testing

  1. Re-run the account-instance inventory and confirm no new instances appear
  2. Verify the SCP is attached to every relevant OU, including newly created ones
  3. Confirm new-account provisioning automation attaches the SCP by default

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1 Logical access is restricted to authorized identity sources
NIST 800-53 CM-7 Least functionality — unnecessary service capability disabled
NIST 800-53 AC-3 Access enforcement through a governed identity source
CIS Controls v8 4.1 Secure configuration process for enterprise assets and software

3. Permission Management

3.1 Configure Permission Sets

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 5.4
NIST 800-53 AC-6

Description

Create least-privilege permission sets.

Rationale

Why This Matters:

  • Least-privilege permission sets limit what any assigned user can do, shrinking the blast radius of a compromised login
  • Permissions boundaries cap the maximum permissions a set can grant, blocking privilege-escalation paths even via inline policies
  • Broadly assigned permissive sets (such as AdministratorAccess) turn any account takeover into full organizational compromise

Attack Prevented: Privilege escalation, lateral movement, blast-radius expansion, excessive standing permissions

ClickOps Implementation

Step 1: Review Permission Sets

  1. Navigate to: Permission sets
  2. Review existing permission sets
  3. Identify overly permissive sets

Step 2: Create Least-Privilege Sets

  1. Create custom permission sets
  2. Use AWS managed policies where possible
  3. Apply inline policies for restrictions

Step 3: Configure Permissions Boundary

  1. Apply permissions boundaries
  2. Limit maximum permissions
  3. Prevent privilege escalation

Code Implementation

Code Pack: Terraform
hth-aws-iam-identity-center-3.01-configure-permission-sets.tf View source on GitHub ↗
# Read-only permission set for auditors and compliance teams
resource "aws_ssoadmin_permission_set" "read_only" {
  instance_arn     = local.sso_instance_arn
  name             = "HTH-ReadOnlyAccess"
  description      = "Read-only access for auditors -- 1-hour session limit"
  session_duration = "PT1H"

  tags = {
    ManagedBy = "how-to-harden"
    Control   = "3.1-configure-permission-sets"
    Profile   = "L1"
  }
}

resource "aws_ssoadmin_managed_policy_attachment" "read_only_policy" {
  instance_arn       = local.sso_instance_arn
  permission_set_arn = aws_ssoadmin_permission_set.read_only.arn
  managed_policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}

# Developer permission set with scoped access
resource "aws_ssoadmin_permission_set" "developer" {
  instance_arn     = local.sso_instance_arn
  name             = "HTH-DeveloperAccess"
  description      = "Scoped developer access -- no IAM or Organizations changes"
  session_duration = "PT4H"

  tags = {
    ManagedBy = "how-to-harden"
    Control   = "3.1-configure-permission-sets"
    Profile   = "L1"
  }
}

resource "aws_ssoadmin_managed_policy_attachment" "developer_poweruser" {
  instance_arn       = local.sso_instance_arn
  permission_set_arn = aws_ssoadmin_permission_set.developer.arn
  managed_policy_arn = "arn:aws:iam::aws:policy/PowerUserAccess"
}

# Admin permission set with a 1-hour session (the AWS minimum) and deny guardrails.
# This set carries no permissions boundary; to add one, use
# aws_ssoadmin_permissions_boundary_attachment with a policy that exists in every
# target account.
resource "aws_ssoadmin_permission_set" "admin" {
  instance_arn     = local.sso_instance_arn
  name             = "HTH-AdminAccess"
  description      = "Full admin access -- 1-hour session, restricted to break-glass"
  session_duration = "PT1H"

  tags = {
    ManagedBy = "how-to-harden"
    Control   = "3.1-configure-permission-sets"
    Profile   = "L1"
  }
}

resource "aws_ssoadmin_managed_policy_attachment" "admin_policy" {
  instance_arn       = local.sso_instance_arn
  permission_set_arn = aws_ssoadmin_permission_set.admin.arn
  managed_policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}

# Deny inline policy to restrict admin from modifying CloudTrail or GuardDuty
resource "aws_ssoadmin_permission_set_inline_policy" "admin_guardrails" {
  instance_arn       = local.sso_instance_arn
  permission_set_arn = aws_ssoadmin_permission_set.admin.arn

  inline_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "DenySecurityServiceModification"
        Effect = "Deny"
        Action = [
          "cloudtrail:DeleteTrail",
          "cloudtrail:StopLogging",
          "guardduty:DeleteDetector",
          "guardduty:DisassociateFromMasterAccount",
          "access-analyzer:DeleteAnalyzer"
        ]
        Resource = "*"
      }
    ]
  })
}
Code Pack: CLI Script
hth-aws-iam-identity-center-3.01-configure-permission-sets.sh View source on GitHub ↗
# List every permission set, then read its session duration and managed policies.
# A read that fails is counted as a failure, never as "nothing attached".
PS_JSON=$(sso_admin list-permission-sets) || {
  fail "3.1 Failed to list permission sets (sso:ListPermissionSets)"
  increment_failed
  summary
}
PS_ARNS=$(echo "${PS_JSON}" | jq -r '.PermissionSets[]')

PS_COUNT=0; READ_ERRORS=0; LONG_SESSION_COUNT=0; ADMIN_PS_COUNT=0
MAX_RECOMMENDED_SECONDS=14400   # PT4H -- see 1.2 for the 1-hour admin recommendation

for PS_ARN in ${PS_ARNS}; do
  PS_DETAIL=$(sso_admin describe-permission-set --permission-set-arn "${PS_ARN}") || {
    fail "3.1 Cannot describe ${PS_ARN##*/} (sso:DescribePermissionSet)"
    READ_ERRORS=$((READ_ERRORS + 1)); continue
  }
  PS_NAME=$(echo "${PS_DETAIL}" | jq -r '.PermissionSet.Name')
  # AWS applies a 1-hour session when none was set on the permission set.
  SESSION_DURATION=$(echo "${PS_DETAIL}" | jq -r '.PermissionSet.SessionDuration // "PT1H"')
  PS_COUNT=$((PS_COUNT + 1))

  if ! DURATION_SECONDS=$(iso8601_seconds "${SESSION_DURATION}"); then
    fail "3.1 '${PS_NAME}': unparseable session duration '${SESSION_DURATION}'"
    READ_ERRORS=$((READ_ERRORS + 1))
  elif [ "${DURATION_SECONDS}" -gt "${MAX_RECOMMENDED_SECONDS}" ]; then
    warn "3.1 Permission set '${PS_NAME}' has long session: ${SESSION_DURATION}"
    LONG_SESSION_COUNT=$((LONG_SESSION_COUNT + 1))
  fi

  POLICIES=$(sso_admin list-managed-policies-in-permission-set --permission-set-arn "${PS_ARN}") || {
    fail "3.1 Cannot list managed policies on '${PS_NAME}' (sso:ListManagedPoliciesInPermissionSet)"
    READ_ERRORS=$((READ_ERRORS + 1)); continue
  }
  if echo "${POLICIES}" | jq -e '[(.AttachedManagedPolicies // [])[].Arn
        | select(test(":iam::aws:policy/AdministratorAccess$"))] | length > 0' >/dev/null; then
    warn "3.1 Permission set '${PS_NAME}' has AdministratorAccess -- apply least privilege"
    ADMIN_PS_COUNT=$((ADMIN_PS_COUNT + 1))
  fi
  info "3.1   ${PS_NAME} (session: ${SESSION_DURATION}, policies: $(echo "${POLICIES}" \
    | jq -r '[(.AttachedManagedPolicies // [])[].Name] | join(",") | if . == "" then "none" else . end'))"
done
# The verdict is only as good as the reads behind it.
if [ "${READ_ERRORS}" -gt 0 ]; then
  fail "3.1 ${READ_ERRORS} permission-set read(s) failed -- the audit is incomplete, not clean"
  increment_failed
elif [ "${PS_COUNT}" -eq 0 ]; then
  warn "3.1 No permission sets found -- create permission sets before assigning access"
  increment_failed
else
  if [ "${LONG_SESSION_COUNT}" -gt 0 ]; then
    warn "3.1 ${LONG_SESSION_COUNT} of ${PS_COUNT} permission set(s) allow sessions longer than 4 hours (AC-12)"
  else
    pass "3.1 All ${PS_COUNT} permission sets have sessions of 4 hours or less"
  fi
  if [ "${ADMIN_PS_COUNT}" -gt 0 ]; then
    warn "3.1 ${ADMIN_PS_COUNT} permission set(s) use AdministratorAccess -- review for least privilege (AC-6)"
  else
    pass "3.1 None of the ${PS_COUNT} permission sets use AdministratorAccess"
  fi
  increment_applied
fi
Code Pack: Sigma Detection Rule
hth-aws-iam-identity-center-3.01-permission-set-modified.yml View source on GitHub ↗
detection:
    selection:
        eventSource: sso.amazonaws.com
        eventName:
            - 'CreatePermissionSet'
            - 'UpdatePermissionSet'
            - 'DeletePermissionSet'
            - 'PutInlinePolicyToPermissionSet'
            - 'DeleteInlinePolicyFromPermissionSet'
            - 'AttachManagedPolicyToPermissionSet'
            - 'DetachManagedPolicyFromPermissionSet'
            - 'AttachCustomerManagedPolicyReferenceToPermissionSet'
            - 'DetachCustomerManagedPolicyReferenceFromPermissionSet'
            - 'PutPermissionsBoundaryToPermissionSet'
            - 'DeletePermissionsBoundaryFromPermissionSet'
    condition: selection
fields:
    - eventTime
    - eventName
    - userIdentity.arn
    - sourceIPAddress
    - requestParameters.permissionSetArn
    - requestParameters.name

3.2 Configure Account Assignments

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 5.4
NIST 800-53 AC-6

Description

Assign access to AWS accounts.

Rationale

Why This Matters:

  • Assigning users only the accounts they need contains a compromised identity to a limited scope rather than the whole organization
  • Group-based assignments make access auditable and revocable in one place instead of scattered per-user grants
  • Regular reviews catch accumulated and unnecessary cross-account access before an attacker can exploit it

Attack Prevented: Lateral movement across accounts, excessive access, privilege creep, unauthorized account access

ClickOps Implementation

Step 1: Review Assignments

  1. Navigate to: AWS accounts
  2. Review current assignments
  3. Identify unnecessary access

Step 2: Apply Least Privilege

  1. Assign minimum required accounts
  2. Use groups for assignments
  3. Regular access reviews

Code Implementation

Code Pack: CLI Script
hth-aws-iam-identity-center-3.02-configure-account-assignments.sh View source on GitHub ↗
# Audit account assignments across all permission sets and active accounts.
PS_JSON=$(sso_admin list-permission-sets) || {
  fail "3.2 Failed to list permission sets (sso:ListPermissionSets)"
  increment_failed
  summary
}
PS_ARNS=$(echo "${PS_JSON}" | jq -r '.PermissionSets[]')

ACCOUNTS_JSON=$(aws_json organizations list-accounts) || {
  fail "3.2 Cannot list organization accounts (organizations:ListAccounts) -- assignments were NOT scanned"
  increment_failed
  summary
}
# State replaces the retiring Status field; read State first.
ACCOUNTS=$(echo "${ACCOUNTS_JSON}" \
  | jq -r '.Accounts[] | select((.State // .Status) == "ACTIVE") | .Id')
ACCOUNT_COUNT=$(echo "${ACCOUNTS}" | grep -c . || true)

TOTAL_ASSIGNMENTS=0; USER_DIRECT_ASSIGNMENTS=0; READ_ERRORS=0

for PS_ARN in ${PS_ARNS}; do
  PS_DETAIL=$(sso_admin describe-permission-set --permission-set-arn "${PS_ARN}") || {
    fail "3.2 Cannot describe ${PS_ARN##*/} (sso:DescribePermissionSet)"
    READ_ERRORS=$((READ_ERRORS + 1)); continue
  }
  PS_NAME=$(echo "${PS_DETAIL}" | jq -r '.PermissionSet.Name')

  for ACCOUNT_ID in ${ACCOUNTS}; do
    ASSIGNMENTS=$(sso_admin list-account-assignments \
      --account-id "${ACCOUNT_ID}" --permission-set-arn "${PS_ARN}") || {
      fail "3.2 Cannot list assignments for ${ACCOUNT_ID} / '${PS_NAME}' (sso:ListAccountAssignments)"
      READ_ERRORS=$((READ_ERRORS + 1)); continue
    }
    COUNT=$(echo "${ASSIGNMENTS}" | jq '(.AccountAssignments // []) | length')
    TOTAL_ASSIGNMENTS=$((TOTAL_ASSIGNMENTS + COUNT))

    # Flag direct user assignments (should use groups instead)
    USER_ASSIGNMENTS=$(echo "${ASSIGNMENTS}" \
      | jq '[(.AccountAssignments // [])[] | select(.PrincipalType == "USER")] | length')
    if [ "${USER_ASSIGNMENTS}" -gt 0 ]; then
      fail "3.2 Account ${ACCOUNT_ID} / PS '${PS_NAME}': ${USER_ASSIGNMENTS} direct user assignment(s) -- use groups instead"
      USER_DIRECT_ASSIGNMENTS=$((USER_DIRECT_ASSIGNMENTS + USER_ASSIGNMENTS))
    fi
  done
done
# Verify the management account has restricted access
info "3.2 Checking management account assignments..."
ORG_JSON=$(aws_json organizations describe-organization) || {
  fail "3.2 Cannot determine the management account (organizations:DescribeOrganization)"
  increment_failed
  summary
}
MGMT_ACCOUNT=$(echo "${ORG_JSON}" | jq -r '.Organization.MasterAccountId // empty')
[ -n "${MGMT_ACCOUNT}" ] || { fail "3.2 DescribeOrganization returned no management account id"; increment_failed; summary; }

MGMT_ASSIGNMENTS=0; MGMT_READ_ERRORS=0
for PS_ARN in ${PS_ARNS}; do
  ASSIGNMENTS=$(sso_admin list-account-assignments \
    --account-id "${MGMT_ACCOUNT}" --permission-set-arn "${PS_ARN}") || {
    MGMT_READ_ERRORS=$((MGMT_READ_ERRORS + 1)); continue
  }
  COUNT=$(echo "${ASSIGNMENTS}" | jq '(.AccountAssignments // []) | length')
  MGMT_ASSIGNMENTS=$((MGMT_ASSIGNMENTS + COUNT))
done

if [ "${MGMT_READ_ERRORS}" -gt 0 ]; then
  fail "3.2 ${MGMT_READ_ERRORS} management-account assignment read(s) failed -- cannot confirm restriction"
  increment_failed
elif [ "${MGMT_ASSIGNMENTS}" -gt 2 ]; then
  warn "3.2 Management account has ${MGMT_ASSIGNMENTS} assignments -- limit access (AC-6(1))"
  increment_applied
else
  pass "3.2 Management account access is appropriately restricted (${MGMT_ASSIGNMENTS} assignments)"
  increment_applied
fi
Code Pack: Sigma Detection Rule
hth-aws-iam-identity-center-3.02-account-assignment-changed.yml View source on GitHub ↗
detection:
    selection:
        eventSource: sso.amazonaws.com
        eventName:
            - 'CreateAccountAssignment'
            - 'DeleteAccountAssignment'
            - 'ProvisionPermissionSet'
    condition: selection
fields:
    - eventTime
    - eventName
    - userIdentity.arn
    - sourceIPAddress
    - requestParameters.permissionSetArn
    - requestParameters.targetId
    - requestParameters.principalType
    - requestParameters.principalId

3.3 Protect Privileged Access

Profile Level: L2 (Walk)

Framework Control
CIS Controls 5.4
NIST 800-53 AC-6(1)

Description

Additional controls for privileged access.

Rationale

Why This Matters:

  • Administrative permission sets carry the highest blast radius, so per-session MFA and short durations limit how a stolen admin session can be abused
  • Separating admin sets from day-to-day access enforces deliberate, auditable elevation instead of always-on privilege
  • Restricting and reviewing admin assignments prevents the privilege accumulation that gives attackers a high-value target

Attack Prevented: Privileged account takeover, admin session hijacking, standing admin privilege, privilege escalation

ClickOps Implementation

Step 1: Create Privileged Permission Sets

  1. Create separate admin permission sets
  2. Apply shorter session duration (minimum 1 hour)
  3. Rely on the instance-wide MFA setting — Settings → Authentication → Multi-factor authentication → Every time they sign in (always-on) (see 1.1); permission sets carry no MFA setting of their own

Step 2: Limit Admin Assignments

  1. Restrict admin access to required users
  2. Use groups for admin access
  3. Regular privileged access reviews

Code Implementation

Code Pack: Terraform
hth-aws-iam-identity-center-3.03-protect-privileged-access.tf View source on GitHub ↗
resource "aws_ssoadmin_permission_set" "privileged_admin" {
  instance_arn     = local.sso_instance_arn
  name             = "HTH-PrivilegedAdmin"
  description      = "Separate admin access -- 1-hour sessions, group-assigned only"
  session_duration = "PT1H"

  tags = {
    ManagedBy = "how-to-harden"
    Control   = "3.3-protect-privileged-access"
  }
}

resource "aws_ssoadmin_managed_policy_attachment" "privileged_admin" {
  instance_arn       = local.sso_instance_arn
  permission_set_arn = aws_ssoadmin_permission_set.privileged_admin.arn
  managed_policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}

# Assign the admin set to one admin GROUP, only in the accounts that need it.
resource "aws_ssoadmin_account_assignment" "privileged_admin_group" {
  for_each = toset(var.admin_account_ids)

  instance_arn       = local.sso_instance_arn
  permission_set_arn = aws_ssoadmin_permission_set.privileged_admin.arn
  principal_type     = "GROUP"
  principal_id       = var.admin_group_id
  target_type        = "AWS_ACCOUNT"
  target_id          = each.value

  # Provision the set only after its policy is attached.
  depends_on = [aws_ssoadmin_managed_policy_attachment.privileged_admin]
}

3.4 Govern the sso:account:access Scope on Customer Managed Applications

Profile Level: L2 (Walk)

Framework Control
CIS Controls 5.4, 6.8
NIST 800-53 AC-6, AC-3, SC-28

Description

Treat the sso:account:access scope — added for customer managed applications on 2026-06-19 — as a privileged grant, because it cannot be narrowed once given. Restrict which applications may request it, and enforce strict handling of the tokens it produces.

Rationale

Why This Matters:

  • AWS documents that this scope grants the application access to all AWS accounts and roles available to the authenticated user — it is not scopable to a subset, so the only meaningful control is deciding which applications receive it at all
  • Because the grant is all-or-nothing, an application that needs one account effectively receives the user’s entire account footprint, converting a narrow integration into a broad standing capability
  • Only the management account or a delegated administrator account can configure this, which makes the configuration itself a high-value target
  • The resulting bearer tokens are the actual credential — leaking one in a log, a URL, or browser-accessible storage hands an attacker the same breadth of access

Attack Prevented: Over-broad application access grants, token theft and replay, cross-account privilege escalation via a compromised integration, credential exposure through logs and URLs

Prerequisites

  • Management account or delegated administrator access
  • An inventory of customer managed applications configured in IAM Identity Center

ClickOps Implementation

Step 1: Inventory Applications Requesting the Scope

  1. Navigate to: IAM Identity Center → Applications → Customer managed
  2. For each application, review whether account access is enabled and which scopes it requests
  3. Record the business justification for each application holding sso:account:access

Step 2: Apply an Approval Gate

  1. Require documented security review before enabling account access for any customer managed application — there is no narrower alternative to fall back on
  2. Restrict who can perform this configuration to a small named group in the management or delegated administrator account
  3. Remove the scope from any application that cannot justify organization-wide account reach

Step 3: Enforce Token-Handling Requirements

  1. Store tokens server-side only — never in browser local storage, session storage, or anywhere client-accessible
  2. Never write tokens to application logs, error messages, or telemetry
  3. Pass the token in the x-amz-sso_bearer_token header, never as a URL query parameter (URLs land in proxy logs, referrer headers, and browser history)
  4. Monitor CloudTrail for the associated authorization and account-access events (see 4.1)

Code Implementation

Code Pack: CLI Script
hth-aws-iam-identity-center-3.04-audit-account-access-scope.sh View source on GitHub ↗
# ListApplications carries InstanceArn (sso_admin); ListApplicationAccessScopes
# takes only the application ARN, so it goes through aws_json.
APPS=$(sso_admin list-applications) || {
  fail "3.4 Failed to list applications (sso:ListApplications)"
  increment_failed
  summary
}
APP_COUNT=0; HOLDERS=0; READ_ERRORS=0
while IFS=$'\t' read -r APP_ARN APP_NAME; do
  [ -n "${APP_ARN}" ] || continue
  APP_COUNT=$((APP_COUNT + 1))
  SCOPES=$(aws_json sso-admin list-application-access-scopes --application-arn "${APP_ARN}") || {
    fail "3.4 Cannot list access scopes for '${APP_NAME}' (sso:ListApplicationAccessScopes)"
    READ_ERRORS=$((READ_ERRORS + 1)); continue
  }
  if echo "${SCOPES}" | jq -e '[(.Scopes // [])[] | select(.Scope == "sso:account:access")] | length > 0' >/dev/null; then
    warn "3.4 Application '${APP_NAME}' holds sso:account:access -- confirm a recorded justification"
    HOLDERS=$((HOLDERS + 1))
  fi
done < <(echo "${APPS}" | jq -r '(.Applications // [])[] | [.ApplicationArn, (.Name // "unnamed")] | @tsv')

Validation & Testing

  1. Confirm the application inventory lists a reviewed justification for every holder of the scope
  2. Grep application logs and log-aggregation indexes for bearer-token patterns to confirm none are being written
  3. Verify tokens are transmitted only in the documented header by inspecting the integration’s outbound requests
  4. Confirm CloudTrail records the account-access events and that they are alertable

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1 Logical access restricted to authorized applications
SOC 2 CC6.3 Access grants are reviewed and justified
NIST 800-53 AC-6 Least privilege for application access grants
NIST 800-53 SC-28 Protection of credentials at rest and in transit
CIS Controls v8 5.4 Restrict administrator privileges to dedicated accounts

Source: Enable account access for customer managed applications


4. Monitoring & Compliance

4.1 Configure CloudTrail Logging

Profile Level: L1 (Crawl)

Framework Control
CIS Controls 8.2
NIST 800-53 AU-2

Description

Enable CloudTrail for IAM Identity Center events.

Rationale

Why This Matters:

  • CloudTrail records authentication events, permission changes, and account assignments, providing the audit trail needed to detect and investigate misuse
  • Without comprehensive logging, attacker activity such as new admin assignments or anomalous sign-ins goes undetected
  • Retained logs support forensic reconstruction after an incident and meet compliance evidence requirements

Attack Prevented: Undetected privilege changes, unnoticed unauthorized access, delayed breach detection, audit gaps

ClickOps Implementation

Step 1: Verify CloudTrail

  1. Ensure organization trail enabled
  2. Verify IAM Identity Center events captured
  3. Configure log retention

Step 2: Monitor Key Events

  1. Authentication events
  2. Permission changes
  3. Account assignments

Code Implementation

Code Pack: Terraform
hth-aws-iam-identity-center-4.01-configure-cloudtrail-logging.tf View source on GitHub ↗
# S3 bucket for CloudTrail logs with encryption and lifecycle
resource "aws_s3_bucket" "cloudtrail_logs" {
  bucket        = var.cloudtrail_bucket_name
  force_destroy = false

  tags = {
    ManagedBy = "how-to-harden"
    Control   = "4.1-configure-cloudtrail-logging"
  }
}

resource "aws_s3_bucket_versioning" "cloudtrail_logs" {
  bucket = aws_s3_bucket.cloudtrail_logs.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "cloudtrail_logs" {
  bucket = aws_s3_bucket.cloudtrail_logs.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = var.cloudtrail_kms_key_id
    }
    bucket_key_enabled = true
  }
}

resource "aws_s3_bucket_public_access_block" "cloudtrail_logs" {
  bucket                  = aws_s3_bucket.cloudtrail_logs.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_lifecycle_configuration" "cloudtrail_logs" {
  bucket = aws_s3_bucket.cloudtrail_logs.id
  rule {
    id     = "archive-old-logs"
    status = "Enabled"
    transition {
      days          = 90
      storage_class = "GLACIER"
    }
    expiration {
      days = 365
    }
  }
}

# S3 bucket policy allowing CloudTrail to write. Both statements are scoped
# with aws:SourceArn to this trail, so no other trail can use the bucket.
data "aws_caller_identity" "current" {}
data "aws_partition" "current" {}
data "aws_region" "current" {}

locals {
  trail_name = "hth-sso-audit-trail"
  trail_arn  = "arn:${data.aws_partition.current.partition}:cloudtrail:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:trail/${local.trail_name}"
}

resource "aws_s3_bucket_policy" "cloudtrail_logs" {
  bucket = aws_s3_bucket.cloudtrail_logs.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AWSCloudTrailAclCheck"
        Effect = "Allow"
        Principal = {
          Service = "cloudtrail.amazonaws.com"
        }
        Action   = "s3:GetBucketAcl"
        Resource = aws_s3_bucket.cloudtrail_logs.arn
        Condition = {
          StringEquals = {
            "aws:SourceArn" = local.trail_arn
          }
        }
      },
      {
        Sid    = "AWSCloudTrailWrite"
        Effect = "Allow"
        Principal = {
          Service = "cloudtrail.amazonaws.com"
        }
        Action   = "s3:PutObject"
        Resource = "${aws_s3_bucket.cloudtrail_logs.arn}/AWSLogs/*"
        Condition = {
          StringEquals = {
            "s3:x-amz-acl"  = "bucket-owner-full-control"
            "aws:SourceArn" = local.trail_arn
          }
        }
      }
    ]
  })
}

# Organization-level CloudTrail capturing SSO management events.
# depends_on: CreateTrail validates the bucket policy and fails with
# InsufficientS3BucketPolicyException if the policy is not in place yet; nothing
# else in the graph orders the trail after the policy.
resource "aws_cloudtrail" "sso_audit" {
  depends_on = [aws_s3_bucket_policy.cloudtrail_logs]

  name                          = local.trail_name
  s3_bucket_name                = aws_s3_bucket.cloudtrail_logs.id
  is_organization_trail         = var.is_organization_trail
  is_multi_region_trail         = true
  include_global_service_events = true
  enable_log_file_validation    = true
  kms_key_id                    = var.cloudtrail_kms_key_id

  event_selector {
    read_write_type           = "All"
    include_management_events = true
  }

  tags = {
    ManagedBy = "how-to-harden"
    Control   = "4.1-configure-cloudtrail-logging"
  }
}
Code Pack: CLI Script
hth-aws-iam-identity-center-4.01-configure-cloudtrail-logging.sh View source on GitHub ↗
# Verify at least one CloudTrail trail is logging management events
info "4.1 Listing CloudTrail trails..."
TRAILS=$(aws_json cloudtrail describe-trails) || {
  fail "4.1 Failed to describe CloudTrail trails (cloudtrail:DescribeTrails)"
  increment_failed
  summary
}

TRAIL_COUNT=$(echo "${TRAILS}" | jq '.trailList | length' 2>/dev/null || echo "0")

if [ "${TRAIL_COUNT}" -eq 0 ]; then
  fail "4.1 No CloudTrail trails found -- SSO events will not be logged (AU-2)"
  increment_failed
  summary
fi

LOGGING_TRAIL_COUNT=0
MGMT_EVENT_TRAIL_COUNT=0

for TRAIL_ARN in $(echo "${TRAILS}" | jq -r '.trailList[].TrailARN' 2>/dev/null); do
  TRAIL_NAME=$(echo "${TRAILS}" | jq -r --arg arn "${TRAIL_ARN}" '.trailList[] | select(.TrailARN == $arn) | .Name')

  # Check if trail is actually logging
  STATUS=$(aws_json cloudtrail get-trail-status --name "${TRAIL_ARN}") || {
    warn "4.1 Cannot read status of trail '${TRAIL_NAME}' (cloudtrail:GetTrailStatus) -- not counted"
    continue
  }
  IS_LOGGING=$(echo "${STATUS}" | jq -r '.IsLogging' 2>/dev/null || echo "false")

  if [ "${IS_LOGGING}" = "true" ]; then
    LOGGING_TRAIL_COUNT=$((LOGGING_TRAIL_COUNT + 1))
  else
    warn "4.1 Trail '${TRAIL_NAME}' exists but is NOT logging"
    continue
  fi

  # Check if trail captures management events (which include SSO events)
  EVENT_SELECTORS=$(aws_json cloudtrail get-event-selectors --trail-name "${TRAIL_ARN}") || {
    warn "4.1 Cannot read event selectors of trail '${TRAIL_NAME}' (cloudtrail:GetEventSelectors) -- not counted"
    continue
  }

  # Check both basic and advanced event selectors
  HAS_MGMT=$(echo "${EVENT_SELECTORS}" | jq '
    (.EventSelectors // [] | any(.IncludeManagementEvents == true)) or
    (.AdvancedEventSelectors // [] | any(.FieldSelectors[] |
      select(.Field == "eventCategory") | .Equals[] == "Management"))
  ' 2>/dev/null || echo "false")

  if [ "${HAS_MGMT}" = "true" ]; then
    pass "4.1 Trail '${TRAIL_NAME}' is logging management events (includes SSO)"
    MGMT_EVENT_TRAIL_COUNT=$((MGMT_EVENT_TRAIL_COUNT + 1))
  else
    warn "4.1 Trail '${TRAIL_NAME}' does not capture management events"
  fi
done
# Verify recent SSO events are present in CloudTrail
info "4.1 Checking for recent SSO events in CloudTrail..."
SSO_EVENTS=$(aws_json cloudtrail lookup-events \
  --lookup-attributes "AttributeKey=EventSource,AttributeValue=sso.amazonaws.com" \
  --max-results 5 2>/dev/null) || {
  warn "4.1 Cannot query CloudTrail events -- verify cloudtrail:LookupEvents permission"
  summary
}

EVENT_COUNT=$(echo "${SSO_EVENTS}" | jq '.Events | length' 2>/dev/null || echo "0")

if [ "${EVENT_COUNT}" -gt 0 ]; then
  pass "4.1 Found ${EVENT_COUNT} recent SSO events in CloudTrail"
  echo "${SSO_EVENTS}" | jq -r '.Events[] | "  - \(.EventName) by \(.Username // "unknown") at \(.EventTime)"' 2>/dev/null || true
else
  warn "4.1 No recent SSO events found -- this may indicate a new deployment or logging gap"
fi
Code Pack: Sigma Detection Rule
hth-aws-iam-identity-center-4.01-cloudtrail-sso-events.yml View source on GitHub ↗
detection:
    selection_idp_changes:
        eventSource: sso.amazonaws.com
        eventName:
            - 'AssociateDirectory'
            - 'DisassociateDirectory'
            - 'CreateInstanceAccessControlAttributeConfiguration'
            - 'UpdateInstanceAccessControlAttributeConfiguration'
            - 'DeleteInstanceAccessControlAttributeConfiguration'
    # External IdP (SAML) configuration changes are console APIs on the
    # sso-directory.amazonaws.com event source, not sso.amazonaws.com.
    selection_idp_config_changes:
        eventSource: sso-directory.amazonaws.com
        eventName:
            - 'CreateExternalIdPConfigurationForDirectory'
            - 'UpdateExternalIdPConfigurationForDirectory'
            - 'DeleteExternalIdPConfigurationForDirectory'
            - 'DisableExternalIdPConfigurationForDirectory'
    # MFA device registration and removal are sso-directory console APIs too.
    selection_mfa_changes:
        eventSource: sso-directory.amazonaws.com
        eventName:
            - 'StartVirtualMfaDeviceRegistration'
            - 'CompleteVirtualMfaDeviceRegistration'
            - 'StartWebAuthnDeviceRegistration'
            - 'CompleteWebAuthnDeviceRegistration'
            - 'DeleteMfaDeviceForUser'
            - 'UpdateMfaDeviceForUser'
    # The directory's MFA device-management settings (control 1.1) are written by
    # this sso console action.
    selection_mfa_settings_changes:
        eventSource: sso.amazonaws.com
        eventName:
            - 'PutMfaDeviceManagementForDirectory'
    selection_instance_changes:
        eventSource: sso.amazonaws.com
        eventName:
            - 'CreateInstance'
            - 'DeleteInstance'
            - 'UpdateInstance'
    condition: 1 of selection_*
fields:
    - eventTime
    - eventName
    - userIdentity.arn
    - userIdentity.principalId
    - sourceIPAddress
    - errorCode
    - errorMessage

4.2 Configure Access Analyzer

Profile Level: L2 (Walk)

Framework Control
CIS Controls 5.4
NIST 800-53 AC-6

Description

Use IAM Access Analyzer for policy validation.

Rationale

Why This Matters:

  • Access Analyzer surfaces policies that grant unintended external or cross-account access before an attacker finds them
  • Automated policy validation catches over-permissive grants and misconfigurations that manual review routinely misses
  • Continuous findings let teams remediate excessive access proactively rather than after an incident

Attack Prevented: Unintended external access, cross-account exposure, policy misconfiguration, excessive permissions

ClickOps Implementation

Step 1: Enable Access Analyzer

  1. Create analyzer for organization
  2. Review findings
  3. Remediate external access

Code Implementation

Code Pack: Terraform
hth-aws-iam-identity-center-4.02-configure-access-analyzer.tf View source on GitHub ↗
# Organization-level IAM Access Analyzer for cross-account visibility
resource "aws_accessanalyzer_analyzer" "organization" {
  analyzer_name = "hth-org-access-analyzer"
  type          = var.analyzer_type

  tags = {
    ManagedBy = "how-to-harden"
    Control   = "4.2-configure-access-analyzer"
  }
}

# Archive only findings for IAM roles trusted by accounts you have named as
# trusted. Without the principal.AWS filter this rule would archive EVERY
# non-public role finding -- including roles trusted by unknown external
# accounts, which is exactly what this control exists to surface.
resource "aws_accessanalyzer_archive_rule" "trusted_external_accounts" {
  count = length(var.trusted_external_account_ids) > 0 ? 1 : 0

  analyzer_name = aws_accessanalyzer_analyzer.organization.analyzer_name
  rule_name     = "trusted-external-accounts"

  filter {
    criteria = "isPublic"
    eq       = ["false"]
  }

  filter {
    criteria = "resourceType"
    eq       = ["AWS::IAM::Role"]
  }

  filter {
    criteria = "principal.AWS"
    eq       = var.trusted_external_account_ids
  }
}

# Route new ACTIVE external-access findings to SNS through EventBridge
resource "aws_cloudwatch_event_rule" "access_analyzer_findings" {
  name        = "hth-access-analyzer-findings"
  description = "New IAM Access Analyzer external-access findings (AC-6)"

  event_pattern = jsonencode({
    source        = ["aws.access-analyzer"]
    "detail-type" = ["Access Analyzer Finding"]
    detail = {
      status = ["ACTIVE"]
    }
  })

  tags = {
    ManagedBy = "how-to-harden"
    Control   = "4.2-configure-access-analyzer"
  }
}

resource "aws_cloudwatch_event_target" "access_analyzer_findings_sns" {
  count = length(var.alarm_sns_topic_arns)

  rule      = aws_cloudwatch_event_rule.access_analyzer_findings.name
  target_id = "hth-access-analyzer-sns-${count.index}"
  arn       = var.alarm_sns_topic_arns[count.index]
}
Code Pack: CLI Script
hth-aws-iam-identity-center-4.02-configure-access-analyzer.sh View source on GitHub ↗
# Find ACTIVE external-access analyzers (type ACCOUNT or ORGANIZATION).
ANALYZERS=$(aws_json accessanalyzer list-analyzers) || {
  fail "4.2 Failed to list analyzers (access-analyzer:ListAnalyzers)"
  increment_failed
  summary
}

EXTERNAL_ARNS=""; EXTERNAL_COUNT=0; ORG_ANALYZER=false
while IFS=$'\t' read -r NAME TYPE STATUS ARN; do
  [ -n "${NAME}" ] || continue
  case "${TYPE}" in
    ACCOUNT|ORGANIZATION)
      if [ "${STATUS}" = "ACTIVE" ]; then
        info "4.2   Active external-access analyzer: ${NAME} (type: ${TYPE})"
        EXTERNAL_COUNT=$((EXTERNAL_COUNT + 1))
        EXTERNAL_ARNS="${EXTERNAL_ARNS} ${ARN}"
        if [ "${TYPE}" = "ORGANIZATION" ]; then ORG_ANALYZER=true; fi
      else
        warn "4.2   Analyzer '${NAME}' is ${STATUS} -- should be ACTIVE"
      fi ;;
    *)
      info "4.2   Analyzer '${NAME}' is type ${TYPE} -- does not cover external access, not counted" ;;
  esac
done < <(echo "${ANALYZERS}" | jq -r '(.analyzers // [])[] | [.name, .type, .status, .arn] | @tsv')
# --apply only: create an organization analyzer, falling back to account scope
# when this is not the management or delegated-administrator account.
info "4.2 --apply: creating organization-level Access Analyzer..."
CREATE_RESULT=$(aws_json accessanalyzer create-analyzer \
  --analyzer-name "hth-org-access-analyzer" --type ORGANIZATION) || {
  warn "4.2 Organization-level analyzer failed -- trying account-level..."
  CREATE_RESULT=$(aws_json accessanalyzer create-analyzer \
    --analyzer-name "hth-account-access-analyzer" --type ACCOUNT) || {
    fail "4.2 Failed to create Access Analyzer (access-analyzer:CreateAnalyzer)"
    increment_failed
    summary
  }
}
NEW_ARN=$(echo "${CREATE_RESULT}" | jq -r '.arn // empty')
if [ -n "${NEW_ARN}" ]; then
  pass "4.2 Created Access Analyzer: ${NEW_ARN##*/}"
  increment_applied
else
  fail "4.2 Analyzer creation returned empty ARN"
  increment_failed
fi
# Review ACTIVE findings on each external-access analyzer; a failed read is a failure.
for ARN in ${EXTERNAL_ARNS}; do
  FINDINGS=$(aws_json accessanalyzer list-findings --analyzer-arn "${ARN}" \
    --filter '{"status": {"eq": ["ACTIVE"]}}') || {
    fail "4.2 Cannot list findings for '${ARN##*/}' (access-analyzer:ListFindings)"
    increment_failed; continue
  }
  FINDING_COUNT=$(echo "${FINDINGS}" | jq '(.findings // []) | length')
  if [ "${FINDING_COUNT}" -gt 0 ]; then
    warn "4.2 Analyzer '${ARN##*/}' has ${FINDING_COUNT} active finding(s) -- review for over-permissive access"
  else
    pass "4.2 Analyzer '${ARN##*/}' has no active findings"
  fi
done

4.3 Use a Customer Managed KMS Key for Identity Center Data

Profile Level: L3 (Run)

Framework Control
CIS Controls 3.11
NIST 800-53 SC-12, SC-13, SC-28

Description

Encrypt IAM Identity Center data with a customer managed AWS KMS key (CMK) instead of the AWS-owned key used by default. Released 2025-09-17, this gives you key-level audit trails and revocation — and, in exchange, a hard operational dependency you must plan for.

Rationale

Why This Matters:

  • The default is an AWS-owned key, which produces no key policy you control and no key usage record in your own account
  • A CMK gives you CloudTrail visibility into every decrypt operation against Identity Center data, plus a key policy you can scope and a revocation path you own
  • Regulated environments frequently require customer-controlled key material and demonstrable key lifecycle governance for identity data
  • The trade-off is real and must be accepted knowingly: the key becomes a single point of failure for authentication itself

Lockout warning — read before enabling. AWS states that if the KMS key is deleted, or disabled, or its permissions are altered such that IAM Identity Center can no longer use it, administrators and users are locked out of IAM Identity Center. This is not a degraded mode; it is loss of access to the service that fronts every AWS account. Pair this control with an explicit key-deletion restriction and a documented break-glass path before enabling it.

Attack Prevented: Unaudited access to identity data at rest, inability to revoke access to encrypted data, non-compliance with customer-managed-key requirements

Prerequisites

  • The key must be a symmetric KMS key
  • The key must be in the same Region as the IAM Identity Center instance
  • The key must be created in the management account
  • If you plan to operate multi-Region, the key must be a multi-Region key — a single-Region key cannot be converted to multi-Region later, so this decision is effectively permanent
  • Verify application compatibility: not all managed applications support customer managed keys

ClickOps Implementation

Step 1: Create the Key

  1. In the management account, in the Identity Center Region, navigate to: KMS → Customer managed keys → Create key
  2. Select Symmetric, and select Multi-Region key if multi-Region operation is plausible — this cannot be changed afterward
  3. Author a key policy granting IAM Identity Center the operations it requires and no more

Step 2: Protect the Key Before You Depend On It

  1. Attach an SCP or key policy statement denying kms:ScheduleKeyDeletion and kms:DisableKey on this key to everyone except a narrowly scoped break-glass principal
  2. Enable key rotation and confirm CloudTrail is recording KMS events for the key
  3. Alert on any disable, deletion-schedule, or policy-change event against the key at critical severity

Step 3: Document the Break-Glass Path

  1. Write down exactly what happens and who does what if the key becomes unusable — administrators will not be able to log in to fix it through Identity Center
  2. Identify an out-of-band administrative path into the management account that does not depend on IAM Identity Center
  3. Test that path before enabling the CMK, not after

Step 4: Enable the Key on Identity Center

  1. Navigate to: IAM Identity Center → Settings → Additional settings tab → Manage encryption
  2. Choose Customer managed key, select or enter your key’s ARN, and save
  3. Validate that managed applications in use still authenticate correctly — compatibility is not universal

Code Implementation

Code Pack: CLI Script
hth-aws-iam-identity-center-4.03-audit-kms-key.sh View source on GitHub ↗
# DescribeInstance reports EncryptionConfigurationDetails:
#   KeyType          AWS_OWNED_KMS_KEY | CUSTOMER_MANAGED_KEY
#   EncryptionStatus UPDATING | ENABLED | UPDATE_FAILED
INSTANCE=$(sso_admin describe-instance) || {
  fail "4.3 Cannot describe the IAM Identity Center instance (sso:DescribeInstance)"
  increment_failed
  summary
}
KEY_TYPE=$(echo "${INSTANCE}" | jq -r '.EncryptionConfigurationDetails.KeyType // empty')
ENC_STATUS=$(echo "${INSTANCE}" | jq -r '.EncryptionConfigurationDetails.EncryptionStatus // empty')

case "${KEY_TYPE}:${ENC_STATUS}" in
  CUSTOMER_MANAGED_KEY:ENABLED)
    pass "4.3 Identity Center data is encrypted with a customer managed KMS key (ENABLED)"
    increment_applied ;;
  CUSTOMER_MANAGED_KEY:UPDATING)
    warn "4.3 Customer managed key configured but encryption is still UPDATING -- re-run to confirm"
    increment_applied ;;
  CUSTOMER_MANAGED_KEY:UPDATE_FAILED)
    fail "4.3 Customer managed key update FAILED: $(echo "${INSTANCE}" \
      | jq -r '.EncryptionConfigurationDetails.EncryptionStatusReason // "no reason given"')"
    increment_failed ;;
  AWS_OWNED_KMS_KEY:*)
    fail "4.3 Identity Center uses the default AWS-owned key -- no customer managed key (SC-28)"
    increment_failed ;;
  *)
    fail "4.3 Encryption configuration not reported (KeyType='${KEY_TYPE}', status='${ENC_STATUS}') -- cannot verify"
    increment_failed ;;
esac

Automation: The pack above audits the key in use. The write — aws sso-admin update-instance --encryption-configuration — is deliberately not packaged: it carries the lockout risk above, so complete Steps 1–3 before running it by hand.

Validation & Testing

  1. Confirm CloudTrail shows Identity Center decrypt operations against your key, proving the CMK is in use
  2. Confirm the deletion/disable restriction is enforced by attempting the action with a non-break-glass principal
  3. Re-test each managed application after enabling and after any key policy change
  4. Rehearse the break-glass path on a schedule

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1 Logical access to encrypted identity data is controlled
NIST 800-53 SC-12 Cryptographic key establishment and management
NIST 800-53 SC-13 Use of validated cryptographic mechanisms
NIST 800-53 SC-28 Protection of information at rest
CIS Controls v8 3.11 Encrypt sensitive data at rest

Source: Customer managed keys for IAM Identity Center


5. Compliance Quick Reference

SOC 2 Trust Services Criteria Mapping

Control ID IAM Identity Center Control Guide Section
CC6.1 MFA enforcement 1.1
CC6.2 Permission sets 3.1
CC6.6 Session duration 1.2
CC7.2 CloudTrail logging 4.1

NIST 800-53 Rev 5 Mapping

Control IAM Identity Center Control Guide Section
IA-2(1) MFA 1.1
IA-8 External IdP 2.1
AC-2 SCIM provisioning 2.2
AC-6 Permission sets 3.1
AC-6 sso:account:access scope governance 3.4
CM-7 Account-instance restriction 2.3
SC-12, SC-28 Customer managed KMS key 4.3
AU-2 CloudTrail 4.1

Appendix A: References

Official AWS Documentation:

API & Developer Tools:

Compliance Frameworks:

Security Incidents:

  • 2025 — IAM Eventual Consistency Exploitation Research: Researchers disclosed that AWS IAM’s eventual consistency model creates a 3-4 second window where deleted access keys remain functional, enabling persistence techniques. AWS applied development fixes and documentation updates in April 2025. (GBHackers Report)
  • November 2025 — Compromised IAM Credentials Mining Campaign: Attackers used compromised IAM user credentials with admin-like privileges to conduct large-scale crypto mining across EC2 instances, detected by GuardDuty. (The Hacker News Report)
  • Note: These are AWS-wide IAM incidents, not specific to IAM Identity Center itself. AWS manages infrastructure patching for Identity Center as a managed service.

Changelog

Date Version Maturity Changes Author
2026-09-25 0.3.0 ai-drafted Validation run (validate-hth-guide, offline fix loop: no AWS console session or credential was available, so 0 surfaces were exercised live and maturity is unchanged). Corrections: 1.1 Steps 1–3 now name the options AWS actually offers — always-on vs context-aware prompting, the “if a user does not yet have a registered MFA device” enforcement setting, and the two MFA types (security keys/built-in authenticators, authenticator apps); the non-existent “Require MFA”, hardware-TOTP and SMS options are gone. 1.2 sets admin permission sets to 1 hour (the AWS minimum, not “1 hour or less”), and the revocation step and 12-hour callout now use a pre-staged identitystore:userId Deny policy, since ending a user’s sessions does not end role sessions in flight. 2.3 states that multi-account permissions can only be declined at enablement (UpdateInstance accepts only PermissionSetsEnabled: true) and names the Dashboard → Prevent account instances path and the sso:CreateInstance action. 3.3 no longer asks for a per-permission-set MFA setting that does not exist. Console-path precision for 1.3, 2.1 and 4.3. Every control now carries a Code Implementation or a sourced **Automation:** verdict (1.1, 2.1, 2.2 ClickOps only; 1.2 and 4.3 partial). Code Packs: the AWS-CLI scripts moved from api/ to cli/; new read-only CLI audits for 1.2, 2.3, 3.4 and 4.3 and Terraform for 2.3 (SCP) and 3.3; the 3.1 and 3.2 audits no longer report PASS when their reads fail (3.2 also reads Account.State, as Status is being retired), every audit exits non-zero after a failure, the 1.2 and 3.2 audits also exit 1 on the violation they audit (a privileged permission set over 1 hour; a direct user assignment), and the 4.2 audit creates an analyzer only with --apply and counts only external-access analyzers. Terraform fixes: 1.3 ABAC sources use $${path:...} and per-action S3 tag keys, 4.1 orders the trail after its bucket policy and scopes it with aws:SourceArn, and 4.2 alerts through EventBridge (Access Analyzer publishes no CloudWatch metric) and archives only findings for named trusted accounts. Sigma: corrected DetachCustomerManagedPolicyReferenceFromPermissionSet and replaced three MFA event names that do not exist with the real sso-directory events. Links: API reference Welcome.html, the Security Reference Architecture IAM Identity Center section, and the AWS Builder Center tools page. Claude Code (Opus 5.5)
2026-08-08 0.2.0 ai-drafted Currency pass. Corrections: 1.1 now states that native MFA configuration is not supported for external identity providers, resolving the contradiction between 1.1 and 2.1 — with an external IdP, MFA must be enforced at the IdP; added the MFA-on-by-default note for directory users and the 8-device registration ceiling; 2.1 gained the matching cross-reference. Rewrote 1.2 around the real three-session model (interactive up to 90 days, permission-set role sessions up to 12 hours and independent of user revocation, application sessions ~1h refresh ending ~30min after the interactive session), including the 12-hour post-deletion exposure window, the Kiro 90-day extension, and the absence of SAML Single Logout in either direction. New controls: 2.3 restrict member-account instances of IAM Identity Center via SCP (enabled by default for orgs enabled after 2023-11-15) with the August 2026 enablement-option changes (Single/Multi-Region/Custom; Multi-Region auto-creates a multi-Region CMK with KMS charges; multi-account permissions now an optional capability enabled by default); 3.4 govern the sso:account:access scope on customer managed applications (2026-06-19, grants all accounts and roles of the authenticated user, non-restrictable, management/delegated-admin only) with token-handling requirements; 4.3 customer managed KMS key (2025-09-17) with its requirements, the administrator/user lockout warning, and break-glass planning. Removed the CIS AWS Foundations Benchmark from this vendor’s hardening_docs — v5.0.0 covers IAM users, root, and access keys, not IAM Identity Center. Tier 3/4 not surveyed this pass. Claude Code (Opus 5)
2026-06-29 0.1.1 ai-drafted Add cheat-sheet Description and Rationale for all controls Claude Code (Opus 4.8)
2025-02-05 0.1.0 ai-drafted Initial guide with MFA, permission sets, and monitoring Claude Code (Opus 4.5)

Contributing

Found an issue or want to improve this guide?