v0.1.0-draft AI Drafted

AWS IAM Identity Center Hardening Guide

Identity Last updated: 2025-02-05

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

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 (Baseline): Essential controls for all organizations
  • L2 (Hardened): Enhanced controls for security-sensitive environments
  • L3 (Maximum Security): 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 (Baseline)

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

Description

Require MFA for all IAM Identity Center users.

Prerequisites

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

ClickOps Implementation

Step 1: Access MFA Settings

  1. Navigate to: IAM Identity CenterSettingsAuthentication
  2. Find MFA configuration

Step 2: Configure MFA Requirement

  1. Select Require MFA
  2. Configure enforcement:
    • Every sign-in (recommended)
    • Context-aware
  3. Save changes

Step 3: Configure MFA Types

  1. Enable authenticator apps
  2. Enable hardware TOTP devices
  3. Enable FIDO2 security keys (recommended)
  4. Disable SMS if possible

Time to Complete: ~30 minutes


1.2 Configure Session Duration

Profile Level: L1 (Baseline)

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

Description

Configure appropriate session duration limits.

ClickOps Implementation

Step 1: Configure Portal Session

  1. Navigate to: SettingsAuthentication
  2. Set session duration
  3. Balance security with usability

Step 2: Configure Permission Set Session

  1. Edit permission set
  2. Set session duration
  3. Apply shorter duration for privileged access

1.3 Configure Attribute-Based Access Control

Profile Level: L2 (Hardened)

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

Description

Enable ABAC for fine-grained access control.

ClickOps Implementation

Step 1: Enable ABAC

  1. Navigate to: SettingsAttributes for access control
  2. Enable attributes
  3. Configure attribute mappings

Step 2: Use in Permission Sets

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

Code Pack: Terraform
hth-aws-iam-identity-center-1.03-configure-abac.tf View source on GitHub ↗
# Enable ABAC attributes for Identity Center
# Maps identity provider attributes to session tags for fine-grained access control
resource "aws_ssoadmin_instance_access_control_attributes" "abac" {
  instance_arn = local.sso_instance_arn

  attribute {
    key = "Department"
    value {
      source = ["${path.root}/Department"]
    }
  }

  attribute {
    key = "CostCenter"
    value {
      source = ["${path.root}/CostCenter"]
    }
  }

  attribute {
    key = "Project"
    value {
      source = ["${path.root}/Project"]
    }
  }
}

# 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
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    = "AllowDepartmentScopedAccess"
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:PutObject",
          "s3:ListBucket"
        ]
        Resource = "*"
        Condition = {
          StringEquals = {
            "s3:ExistingObjectTag/Department" = "$${aws:PrincipalTag/Department}"
          }
        }
      }
    ]
  })
}

2. Identity Source Configuration

2.1 Configure External Identity Provider

Profile Level: L1 (Baseline)

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

Description

Connect to external IdP for centralized identity.

ClickOps Implementation

Step 1: Change Identity Source

  1. Navigate to: SettingsIdentity source
  2. Click 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

2.2 Configure Automatic Provisioning

Profile Level: L2 (Hardened)

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

Description

Enable SCIM for automatic user provisioning.

ClickOps Implementation

Step 1: Enable SCIM

  1. Navigate to: SettingsIdentity 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

3. Permission Management

3.1 Configure Permission Sets

Profile Level: L1 (Baseline)

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

Description

Create least-privilege permission sets.

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 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 short session and boundary
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: API Script
hth-aws-iam-identity-center-3.01-configure-permission-sets.sh View source on GitHub ↗
# List all permission sets and their configurations
info "3.1 Retrieving all permission sets..."
PS_ARNS=$(sso_admin list-permission-sets \
  | jq -r '.PermissionSets[]' 2>/dev/null) || {
  fail "3.1 Failed to list permission sets"
  increment_failed
  summary
  exit 0
}

PS_COUNT=0
LONG_SESSION_COUNT=0
MAX_RECOMMENDED_DURATION="PT4H"

for PS_ARN in ${PS_ARNS}; do
  PS_DETAIL=$(sso_admin describe-permission-set \
    --permission-set-arn "${PS_ARN}" 2>/dev/null) || continue

  PS_NAME=$(echo "${PS_DETAIL}" | jq -r '.PermissionSet.Name')
  SESSION_DURATION=$(echo "${PS_DETAIL}" | jq -r '.PermissionSet.SessionDuration')
  PS_COUNT=$((PS_COUNT + 1))

  # Check for overly long session durations (> 4 hours)
  DURATION_SECONDS=$(echo "${SESSION_DURATION}" | sed 's/PT//;s/H/*3600+/;s/M/*60+/;s/S//;s/+$//' | bc 2>/dev/null || echo "0")
  if [ "${DURATION_SECONDS}" -gt 14400 ]; then
    warn "3.1 Permission set '${PS_NAME}' has long session: ${SESSION_DURATION}"
    LONG_SESSION_COUNT=$((LONG_SESSION_COUNT + 1))
  fi

  # List managed policies attached
  MANAGED_POLICIES=$(sso_admin list-managed-policies-in-permission-set \
    --permission-set-arn "${PS_ARN}" \
    | jq -r '.AttachedManagedPolicies[].Name' 2>/dev/null || echo "none")

  info "3.1   ${PS_NAME} (session: ${SESSION_DURATION}, policies: ${MANAGED_POLICIES})"
done
# Flag permission sets with AdministratorAccess or overly broad policies
info "3.1 Checking for overly broad permission sets..."
ADMIN_PS_COUNT=0

for PS_ARN in ${PS_ARNS}; do
  PS_DETAIL=$(sso_admin describe-permission-set \
    --permission-set-arn "${PS_ARN}" 2>/dev/null) || continue
  PS_NAME=$(echo "${PS_DETAIL}" | jq -r '.PermissionSet.Name')

  MANAGED_POLICIES=$(sso_admin list-managed-policies-in-permission-set \
    --permission-set-arn "${PS_ARN}" \
    | jq -r '.AttachedManagedPolicies[].Arn' 2>/dev/null || true)

  if echo "${MANAGED_POLICIES}" | grep -q "AdministratorAccess"; then
    warn "3.1 Permission set '${PS_NAME}' has AdministratorAccess -- apply least privilege"
    ADMIN_PS_COUNT=$((ADMIN_PS_COUNT + 1))
  fi
done

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 No permission sets use AdministratorAccess"
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'
            - 'DetachCustomerManagedPolicyReferenceToPermissionSet'
            - 'PutPermissionsBoundaryToPermissionSet'
            - 'DeletePermissionsBoundaryFromPermissionSet'
    condition: selection
fields:
    - eventTime
    - eventName
    - userIdentity.arn
    - sourceIPAddress
    - requestParameters.permissionSetArn
    - requestParameters.name

3.2 Configure Account Assignments

Profile Level: L1 (Baseline)

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

Description

Assign access to AWS accounts.

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 Pack: API Script
hth-aws-iam-identity-center-3.02-configure-account-assignments.sh View source on GitHub ↗
# Audit account assignments across all permission sets and accounts
info "3.2 Retrieving permission sets..."
PS_ARNS=$(sso_admin list-permission-sets \
  | jq -r '.PermissionSets[]' 2>/dev/null) || {
  fail "3.2 Failed to list permission sets"
  increment_failed
  summary
  exit 0
}

# Get all AWS accounts in the organization
info "3.2 Retrieving organization accounts..."
ACCOUNTS=$(aws_json organizations list-accounts \
  | jq -r '.Accounts[] | select(.Status == "ACTIVE") | .Id' 2>/dev/null) || {
  warn "3.2 Cannot list org accounts -- verify organizations:ListAccounts permission"
  ACCOUNTS=""
}

TOTAL_ASSIGNMENTS=0
USER_DIRECT_ASSIGNMENTS=0

for PS_ARN in ${PS_ARNS}; do
  PS_DETAIL=$(sso_admin describe-permission-set \
    --permission-set-arn "${PS_ARN}" 2>/dev/null) || 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}" 2>/dev/null) || continue

    ASSIGNMENT_LIST=$(echo "${ASSIGNMENTS}" | jq -r '.AccountAssignments[]' 2>/dev/null) || continue

    # Count and classify assignments
    ACCOUNT_ASSIGNMENT_COUNT=$(echo "${ASSIGNMENTS}" | jq '.AccountAssignments | length' 2>/dev/null || echo "0")
    TOTAL_ASSIGNMENTS=$((TOTAL_ASSIGNMENTS + ACCOUNT_ASSIGNMENT_COUNT))

    # Flag direct user assignments (should use groups instead)
    USER_ASSIGNMENTS=$(echo "${ASSIGNMENTS}" \
      | jq '[.AccountAssignments[] | select(.PrincipalType == "USER")] | length' 2>/dev/null || echo "0")
    if [ "${USER_ASSIGNMENTS}" -gt 0 ]; then
      warn "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 management account has restricted access
info "3.2 Checking management account assignments..."
MGMT_ACCOUNT=$(aws_json organizations describe-organization \
  | jq -r '.Organization.MasterAccountId' 2>/dev/null) || {
  warn "3.2 Cannot determine management account -- verify organizations:DescribeOrganization permission"
  increment_applied
  summary
  exit 0
}

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

if [ "${MGMT_ASSIGNMENTS}" -gt 2 ]; then
  warn "3.2 Management account (${MGMT_ACCOUNT}) has ${MGMT_ASSIGNMENTS} assignments -- limit access (AC-6(1))"
else
  pass "3.2 Management account access is appropriately restricted (${MGMT_ASSIGNMENTS} assignments)"
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 (Hardened)

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

Description

Additional controls for privileged access.

ClickOps Implementation

Step 1: Create Privileged Permission Sets

  1. Create separate admin permission sets
  2. Apply shorter session duration
  3. Require MFA for every session

Step 2: Limit Admin Assignments

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

4. Monitoring & Compliance

4.1 Configure CloudTrail Logging

Profile Level: L1 (Baseline)

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

Description

Enable CloudTrail for IAM Identity Center events.

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 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
data "aws_caller_identity" "current" {}

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
      },
      {
        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"
          }
        }
      }
    ]
  })
}

# Organization-level CloudTrail capturing SSO management events
resource "aws_cloudtrail" "sso_audit" {
  name                          = "hth-sso-audit-trail"
  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: API 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"
  increment_failed
  summary
  exit 0
}

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
  exit 0
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}" 2>/dev/null) || 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}" 2>/dev/null) || 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
  exit 0
}

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'
    selection_mfa_changes:
        eventSource: sso.amazonaws.com
        eventName:
            - 'RegisterMfaDevice'
            - 'DeregisterMfaDevice'
            - 'UpdateMfaDevice'
    selection_instance_changes:
        eventSource: sso.amazonaws.com
        eventName:
            - 'CreateInstance'
            - 'DeleteInstance'
            - 'UpdateInstance'
    condition: selection_idp_changes or selection_mfa_changes or selection_instance_changes
fields:
    - eventTime
    - eventName
    - userIdentity.arn
    - userIdentity.principalId
    - sourceIPAddress
    - errorCode
    - errorMessage

4.2 Configure Access Analyzer

Profile Level: L2 (Hardened)

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

Description

Use IAM Access Analyzer for policy validation.

ClickOps Implementation

Step 1: Enable Access Analyzer

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

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 rule to auto-suppress known trusted cross-account access
resource "aws_accessanalyzer_archive_rule" "trusted_org_accounts" {
  analyzer_name = aws_accessanalyzer_analyzer.organization.analyzer_name
  rule_name     = "trusted-organization-accounts"

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

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

# CloudWatch alarm for new Access Analyzer findings
resource "aws_cloudwatch_metric_alarm" "access_analyzer_findings" {
  alarm_name          = "hth-access-analyzer-new-findings"
  alarm_description   = "Alert on new IAM Access Analyzer findings (AC-6)"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 1
  metric_name         = "ActiveFindings"
  namespace           = "AccessAnalyzer"
  period              = 300
  statistic           = "Maximum"
  threshold           = 0
  treat_missing_data  = "notBreaching"

  dimensions = {
    AnalyzerName = aws_accessanalyzer_analyzer.organization.analyzer_name
  }

  alarm_actions = var.alarm_sns_topic_arns

  tags = {
    ManagedBy = "how-to-harden"
    Control   = "4.2-configure-access-analyzer"
  }
}
Code Pack: API Script
hth-aws-iam-identity-center-4.02-configure-access-analyzer.sh View source on GitHub ↗
# Check for existing IAM Access Analyzer instances
info "4.2 Listing Access Analyzer instances..."
ANALYZERS=$(aws_json accessanalyzer list-analyzers 2>/dev/null) || {
  fail "4.2 Failed to list analyzers -- verify accessanalyzer:ListAnalyzers permission"
  increment_failed
  summary
  exit 0
}

ANALYZER_COUNT=$(echo "${ANALYZERS}" | jq '.analyzers | length' 2>/dev/null || echo "0")
ACTIVE_COUNT=0
ORG_ANALYZER=false

for i in $(seq 0 $((ANALYZER_COUNT - 1))); do
  ANALYZER=$(echo "${ANALYZERS}" | jq ".analyzers[${i}]")
  NAME=$(echo "${ANALYZER}" | jq -r '.name')
  STATUS=$(echo "${ANALYZER}" | jq -r '.status')
  TYPE=$(echo "${ANALYZER}" | jq -r '.type')

  if [ "${STATUS}" = "ACTIVE" ]; then
    ACTIVE_COUNT=$((ACTIVE_COUNT + 1))
    info "4.2   Active analyzer: ${NAME} (type: ${TYPE})"
    if [ "${TYPE}" = "ORGANIZATION" ]; then
      ORG_ANALYZER=true
    fi
  else
    warn "4.2   Analyzer '${NAME}' is ${STATUS} -- should be ACTIVE"
  fi
done
# Create an organization-level Access Analyzer
info "4.2 Creating organization-level Access Analyzer..."
CREATE_RESULT=$(aws_json accessanalyzer create-analyzer \
  --analyzer-name "hth-org-access-analyzer" \
  --type ORGANIZATION 2>/dev/null) || {
  # Fall back to account-level if org-level fails (not org management account)
  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 2>/dev/null) || {
    fail "4.2 Failed to create Access Analyzer"
    increment_failed
    summary
    exit 0
  }
}

NEW_ARN=$(echo "${CREATE_RESULT}" | jq -r '.arn // empty' 2>/dev/null || true)
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 Access Analyzer findings
info "4.2 Checking for active findings..."
for i in $(seq 0 $((ANALYZER_COUNT - 1))); do
  ANALYZER=$(echo "${ANALYZERS}" | jq ".analyzers[${i}]")
  NAME=$(echo "${ANALYZER}" | jq -r '.name')
  ARN=$(echo "${ANALYZER}" | jq -r '.arn')
  STATUS=$(echo "${ANALYZER}" | jq -r '.status')

  [ "${STATUS}" != "ACTIVE" ] && continue

  FINDINGS=$(aws_json accessanalyzer list-findings \
    --analyzer-arn "${ARN}" \
    --filter '{"status": {"eq": ["ACTIVE"]}}' 2>/dev/null) || continue

  FINDING_COUNT=$(echo "${FINDINGS}" | jq '.findings | length' 2>/dev/null || echo "0")

  if [ "${FINDING_COUNT}" -gt 0 ]; then
    warn "4.2 Analyzer '${NAME}' has ${FINDING_COUNT} active finding(s) -- review for over-permissive access"
  else
    pass "4.2 Analyzer '${NAME}' has no active findings"
  fi
done

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
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
2025-02-05 0.1.0 draft Initial guide with MFA, permission sets, and monitoring Claude Code (Opus 4.5)

Contributing

Found an issue or want to improve this guide?