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 (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
- Authentication & MFA
- Identity Source Configuration
- Permission Management
- Monitoring & Compliance
- 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
- Navigate to: IAM Identity Center → Settings → Identity source
- If the source is Identity Center directory, AWS Managed Microsoft AD, or AD Connector, continue with Steps 1-3 below
- 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
- Navigate to: IAM Identity Center → Settings → Authentication tab → Multi-factor authentication → Configure
- 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
- 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
- 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
- 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
- Choose Save changes
Step 3: Choose MFA Types
- Under Users can authenticate with these MFA types, enable Security keys and built-in authenticators (FIDO2, phishing-resistant — recommended)
- Enable Authenticator apps (TOTP) only if users need a fallback to a security key
- IAM Identity Center offers no SMS option and no hardware-TOTP option, so there is nothing to disable
- 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
- Navigate to: Settings → Authentication tab → Session duration → Configure
- 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
- 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
- Edit each permission set
- Set session duration — the range is 1 to 12 hours
- 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
- Record that user deletion alone leaves account sessions live for up to their configured duration
- Pre-stage a Deny policy keyed on
identitystore:userIdin 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 - Test the procedure so responders are not discovering the gap during an incident
Code Implementation
Code Pack: CLI Script
# 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
- Navigate to: Settings → in the Attributes for access control information box, choose Enable
- Open the Attributes for access control tab → Manage attributes → Add attribute
- 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
- Create ABAC-aware policies
- Reference user attributes
- Implement tag-based access
Code Implementation
Code Pack: Terraform
# 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
- Navigate to: Settings → Identity source tab
- Click Actions → 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
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
- 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
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
UpdateInstanceAPI accepts onlyPermissionSetsEnabled: 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
- Determine whether any member account has already created an account instance of IAM Identity Center
- For each one found, identify its owner, its identity source, and the applications assigned through it
- Plan migration of any legitimate use onto the organization instance before restricting creation
Step 2: Restrict Creation via Service Control Policy
- 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
- Create or edit an SCP that denies
sso:CreateInstancefor member accounts (AWS’s example allow-lists specific accounts with aStringNotEqualscondition onaws:PrincipalAccount) - 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
- From a member account, attempt to create an account instance and confirm it is denied
- 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
# 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
# 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
- Re-run the account-instance inventory and confirm no new instances appear
- Verify the SCP is attached to every relevant OU, including newly created ones
- 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
- 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 Implementation
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 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
# 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
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
- 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 Implementation
Code Pack: CLI Script
# 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
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
- Create separate admin permission sets
- Apply shorter session duration (minimum 1 hour)
- 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
- Restrict admin access to required users
- Use groups for admin access
- Regular privileged access reviews
Code Implementation
Code Pack: Terraform
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
- Navigate to: IAM Identity Center → Applications → Customer managed
- For each application, review whether account access is enabled and which scopes it requests
- Record the business justification for each application holding
sso:account:access
Step 2: Apply an Approval Gate
- Require documented security review before enabling account access for any customer managed application — there is no narrower alternative to fall back on
- Restrict who can perform this configuration to a small named group in the management or delegated administrator account
- Remove the scope from any application that cannot justify organization-wide account reach
Step 3: Enforce Token-Handling Requirements
- Store tokens server-side only — never in browser local storage, session storage, or anywhere client-accessible
- Never write tokens to application logs, error messages, or telemetry
- Pass the token in the
x-amz-sso_bearer_tokenheader, never as a URL query parameter (URLs land in proxy logs, referrer headers, and browser history) - Monitor CloudTrail for the associated authorization and account-access events (see 4.1)
Code Implementation
Code Pack: CLI Script
# 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
- Confirm the application inventory lists a reviewed justification for every holder of the scope
- Grep application logs and log-aggregation indexes for bearer-token patterns to confirm none are being written
- Verify tokens are transmitted only in the documented header by inspecting the integration’s outbound requests
- 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
- 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 Implementation
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. 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
# 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
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
- Create analyzer for organization
- Review findings
- Remediate external access
Code Implementation
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 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
# 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
- In the management account, in the Identity Center Region, navigate to: KMS → Customer managed keys → Create key
- Select Symmetric, and select Multi-Region key if multi-Region operation is plausible — this cannot be changed afterward
- 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
- Attach an SCP or key policy statement denying
kms:ScheduleKeyDeletionandkms:DisableKeyon this key to everyone except a narrowly scoped break-glass principal - Enable key rotation and confirm CloudTrail is recording KMS events for the key
- Alert on any disable, deletion-schedule, or policy-change event against the key at critical severity
Step 3: Document the Break-Glass Path
- 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
- Identify an out-of-band administrative path into the management account that does not depend on IAM Identity Center
- Test that path before enabling the CMK, not after
Step 4: Enable the Key on Identity Center
- Navigate to: IAM Identity Center → Settings → Additional settings tab → Manage encryption
- Choose Customer managed key, select or enter your key’s ARN, and save
- Validate that managed applications in use still authenticate correctly — compatibility is not universal
Code Implementation
Code Pack: CLI Script
# 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
- Confirm CloudTrail shows Identity Center decrypt operations against your key, proving the CMK is in use
- Confirm the deletion/disable restriction is enforced by attempting the action with a non-break-glass principal
- Re-test each managed application after enabling and after any key policy change
- 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:
- IAM Identity Center User Guide
- IAM Identity Center Security
- Authentication concepts (session types and durations)
- Enable MFA · Configure MFA
- Enable IAM Identity Center (instance types and account instances)
- Enable account access for customer managed applications
- Customer managed keys for IAM Identity Center
- IAM Identity Center document history
- 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 |
|---|---|---|---|---|
| 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?
- 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