AWS IAM Identity Center Hardening Guide
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
- Authentication & MFA
- Identity Source Configuration
- Permission Management
- Monitoring & Compliance
- 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
- Navigate to: IAM Identity Center → Settings → Authentication
- Find MFA configuration
Step 2: Configure MFA Requirement
- Select Require MFA
- Configure enforcement:
- Every sign-in (recommended)
- Context-aware
- Save changes
Step 3: Configure MFA Types
- Enable authenticator apps
- Enable hardware TOTP devices
- Enable FIDO2 security keys (recommended)
- 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
- Navigate to: Settings → Authentication
- Set session duration
- Balance security with usability
Step 2: Configure Permission Set Session
- Edit permission set
- Set session duration
- 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
- Navigate to: Settings → Attributes for access control
- Enable attributes
- Configure attribute mappings
Step 2: Use in Permission Sets
- Create ABAC-aware policies
- Reference user attributes
- Implement tag-based access
Code Pack: Terraform
# 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
- Navigate to: Settings → Identity source
- Click Change identity source
- Select external identity provider
Step 2: Configure SAML/SCIM
- Configure SAML 2.0 settings
- Enable automatic provisioning (SCIM)
- Configure attribute mappings
Step 3: Test and Migrate
- Test authentication
- Migrate users from Identity Center directory
- 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
- Navigate to: Settings → Identity source
- Enable automatic provisioning
- Generate SCIM endpoint and token
Step 2: Configure IdP
- Configure SCIM in identity provider
- Map user attributes
- 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
- Navigate to: Permission sets
- Review existing permission sets
- Identify overly permissive sets
Step 2: Create Least-Privilege Sets
- Create custom permission sets
- Use AWS managed policies where possible
- Apply inline policies for restrictions
Step 3: Configure Permissions Boundary
- Apply permissions boundaries
- Limit maximum permissions
- Prevent privilege escalation
Code Pack: Terraform
# 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
# 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
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
- Navigate to: AWS accounts
- Review current assignments
- Identify unnecessary access
Step 2: Apply Least Privilege
- Assign minimum required accounts
- Use groups for assignments
- Regular access reviews
Code Pack: API Script
# 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
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
- Create separate admin permission sets
- Apply shorter session duration
- Require MFA for every session
Step 2: Limit Admin Assignments
- Restrict admin access to required users
- Use groups for admin access
- 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
- Ensure organization trail enabled
- Verify IAM Identity Center events captured
- Configure log retention
Step 2: Monitor Key Events
- Authentication events
- Permission changes
- Account assignments
Code Pack: Terraform
# 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
# 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
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
- Create analyzer for organization
- Review findings
- Remediate external access
Code Pack: Terraform
# 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
# 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:
- IAM Identity Center User Guide
- IAM Identity Center Security
- Security Best Practices in IAM
- Security Reference Architecture - IAM Identity Center
- AWS IAM Best Practices
API & Developer Tools:
- IAM Identity Center API Reference
- AWS CLI - SSO Admin Commands
- AWS SDKs (Boto3, JavaScript, Go, Java, .NET, etc.)
- AWS CloudFormation IAM Identity Center Resources
- GitHub Organization (aws)
Compliance Frameworks:
- SOC 1/2/3 Type II (12-month audit periods) — via AWS Artifact
- ISO/IEC 27001:2022, ISO 27017:2015, ISO 27018:2019 — via AWS ISO Certified
- FedRAMP (High, Moderate baselines) — via AWS Compliance Programs
- PCI DSS Level 1, HIPAA, HITRUST, DoD SRG, CSA STAR
- Full Compliance Programs List
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?
- Report outdated information: Open an issue with tag
content-outdated - Propose new controls: Open an issue with tag
new-control - Submit improvements: See Contributing Guide