AWS Identity

AWS IAM Identity Center at Scale: Permission Sets, ABAC, and Federated Multi-Account Access

IAM users do not scale. The moment you have more than a handful of accounts, long-lived keys and per-account logins become a breach waiting to happen and an audit you will fail. IAM Identity Center (the service formerly called AWS SSO) is the fix: one place to map workforce identities to accounts, short-lived credentials only, and access expressed as reusable permission sets instead of thousands of hand-rolled roles. This guide builds that system end to end — external IdP federation, permission set design, ABAC with session tags, automation, the CLI workflow your engineers actually live in, and the audit trail your security team will ask for.

The three moving parts: one mental model

Everything in Identity Center reduces to a single relationship. Hold this and the rest follows:

assignment = (principal) x (permission set) x (target account)

principal       -> a user or group from the identity source
permission set  -> a template that becomes an IAM role in each target account
target account  -> any member account in the AWS Organization

When you create an assignment, Identity Center provisions an IAM role named AWSReservedSSO_<PermissionSetName>_<hash> into the target account and keeps it in sync. Your users never touch that role directly. They authenticate once at the access portal, pick an account-plus-role tile, and Identity Center hands back short-lived STS credentials for that provisioned role. There are no IAM users, no access keys, nothing to rotate.

A few facts that trip up people who know plain IAM:

The strategic win is not “single sign-on.” It is that access becomes data — a small set of permission sets and group-to-account assignments you can review, diff in Git, and recertify. That is the thing you cannot do with sprawling per-account IAM.

Step 1 — Architecture: identity source, permission sets, account assignments

Before federation, decide the identity source. Identity Center supports three:

Identity source Who manages users/groups Use when
Identity Center directory Identity Center (built-in store) No corporate IdP, or a small org / lab
External identity provider Entra ID, Okta, Ping, etc. via SAML + SCIM You have a workforce IdP (the common enterprise case)
Active Directory AWS Managed Microsoft AD or on-prem via AD Connector AD is your source of truth and you are not moving to a cloud IdP

For any real org, the answer is external IdP. Your joiners/movers/leavers process already runs there; duplicating it in AWS is how access drifts.

Enable the service and confirm your home Region and delegation up front:

# Run from the management account once, in your chosen home Region.
aws sso-admin list-instances --region us-east-1

# Delegate administration to a dedicated account so you stop using the mgmt account.
aws organizations register-delegated-administrator \
  --account-id 222222222222 \
  --service-principal sso.amazonaws.com

list-instances returns the Instance ARN and the Identity Store ID. You will pass these to almost every command below, so capture them:

export SSO_INSTANCE_ARN=$(aws sso-admin list-instances \
  --query 'Instances[0].InstanceArn' --output text)
export IDENTITY_STORE_ID=$(aws sso-admin list-instances \
  --query 'Instances[0].IdentityStoreId' --output text)

One home Region, full stop. The home Region cannot be changed without deleting and recreating the instance — which destroys every assignment. Pick the Region close to your IdP and your admins, and treat it as permanent.

Step 2 — Federating an external IdP (Entra ID / Okta) over SAML + SCIM

Two protocols do two distinct jobs, and conflating them is the most common mistake:

You need both. SAML without SCIM means groups never appear in Identity Center and just-in-time-created users cannot be pre-assigned to accounts.

The exchange is a metadata swap. In the Identity Center console under Settings -> Identity source -> Change to External identity provider, you download the Identity Center SAML metadata and ACS URL, and upload your IdP’s metadata. In your IdP you create an enterprise application (Entra ID has a gallery app literally named “AWS IAM Identity Center”; Okta has the equivalent) and paste the Identity Center values in.

The assertion contract that matters: Identity Center expects the user’s identifier in the SAML Subject (NameID), and it must match the SCIM-provisioned userName exactly. If SAML sends UPN but SCIM provisions mail, logins fail with a “user not found” style error even though the directory looks correct. Standardize both on the same attribute (UPN/email is the usual choice).

For SCIM, Identity Center generates a SCIM endpoint URL and a bearer access token; you paste both into the IdP’s provisioning configuration:

SCIM endpoint : https://scim.<region>.amazonaws.com/<tenant-id>/scim/v2/
Bearer token  : <one-time secret shown once; store in your secrets manager>

Then scope provisioning to only the groups that need AWS — never sync the entire directory. In Entra ID that is the application’s Users and groups assignment combined with provisioning Scope = Sync only assigned users and groups; in Okta it is the app’s group push rules.

Verify provisioning landed before going further:

# Groups pushed by SCIM should appear here.
aws identitystore list-groups \
  --identity-store-id "$IDENTITY_STORE_ID" \
  --query 'Groups[].[DisplayName,GroupId]' --output table

# And users.
aws identitystore list-users \
  --identity-store-id "$IDENTITY_STORE_ID" \
  --query 'Users[].[UserName,UserId]' --output table

The SCIM bearer token expires (one year) and is shown exactly once. Put a calendar reminder a month out and store the token in Secrets Manager the moment it is generated. A silently expired SCIM token means new hires stop appearing in AWS and nobody notices until a ticket lands.

Step 3 — Designing permission sets: managed vs inline policies and session duration

A permission set carries up to four things, each becoming part of the provisioned role:

  1. AWS managed and/or customer managed policies — attached by reference.
  2. An inline policy — embedded directly in the permission set.
  3. A permissions boundary — a ceiling on the role (highly recommended for any non-admin set).
  4. Session settingsSessionDuration (ISO-8601, PT1H to PT12H) and relay state.

Design principle: reference, do not embed. Prefer attaching a customer managed policy by name over pasting a large inline policy. Why? An inline policy is duplicated into every provisioned role and only updates when you re-provision the permission set. A customer managed policy is maintained once in IAM (per account, but as a single named object you control via IaC) and the role references it. AWS managed policies are fine for coarse, well-known roles (read-only, billing) but are too broad for anything privileged.

A clean, minimal permission set with a customer managed policy, an inline policy for the few bespoke statements, a boundary, and a one-hour session:

# 1. Create the permission set with a short session.
aws sso-admin create-permission-set \
  --instance-arn "$SSO_INSTANCE_ARN" \
  --name "PlatformReadOnly" \
  --description "Read-only platform access, 1h sessions" \
  --session-duration "PT1H"

export PS_ARN=$(aws sso-admin list-permission-sets \
  --instance-arn "$SSO_INSTANCE_ARN" \
  --query 'PermissionSets[?contains(@, `ps-`)]' --output text | head -n1)

# 2. Attach an AWS managed policy by ARN (coarse baseline).
aws sso-admin attach-managed-policy-to-permission-set \
  --instance-arn "$SSO_INSTANCE_ARN" \
  --permission-set-arn "$PS_ARN" \
  --managed-policy-arn "arn:aws:iam::aws:policy/ReadOnlyAccess"

# 3. Reference a CUSTOMER managed policy (must exist by this name in every target account).
aws sso-admin attach-customer-managed-policy-reference-to-permission-set \
  --instance-arn "$SSO_INSTANCE_ARN" \
  --permission-set-arn "$PS_ARN" \
  --customer-managed-policy-reference Name=platform-readonly-extra,Path=/

# 4. Add a permissions boundary so the role can never exceed this ceiling.
aws sso-admin put-permissions-boundary \
  --instance-arn "$SSO_INSTANCE_ARN" \
  --permission-set-arn "$PS_ARN" \
  --permissions-boundary CustomerManagedPolicyReference={Name=platform-boundary,Path=/}

A customer managed policy reference is by name. The policy named platform-readonly-extra must already exist in every account you assign this permission set to, or provisioning fails for that account. This is exactly why landing-zone teams bake these named policies into account baselines (Control Tower customizations, StackSets, or Terraform) before assigning permission sets.

Session duration is a security control, not a convenience knob. Map duration to blast radius:

Permission set Suggested SessionDuration Rationale
Break-glass / org admin PT1H Minimize the window a stolen session is useful
Platform / infra engineer PT2H to PT4H Balance interruptions against exposure
Read-only / auditor PT8H Low blast radius, long analysis sessions
CI bound human approver PT1H Privileged action, tight window

Step 4 — ABAC: IdP attributes as session tags, and tag-conditioned policies

This is where Identity Center stops being “SSO with extra steps” and becomes a force multiplier. Attribute-based access control lets one permission set serve many teams by passing IdP attributes as session tags, then writing policies whose conditions reference those tags. Instead of PlatformAccess-TeamA, PlatformAccess-TeamB, … you have one PlatformAccess set and the attribute decides what it can touch.

First, turn on attributes for access control on the instance and map IdP attributes to session-tag keys:

aws sso-admin create-instance-access-control-attribute-configuration \
  --instance-arn "$SSO_INSTANCE_ARN" \
  --instance-access-control-attribute-configuration \
  'AccessControlAttributes=[{Key=team,Value={Source=["${path:enterprise.department}"]}},{Key=project,Value={Source=["${path:enterprise.costCenter}"]}}]'

The ${path:...} values pull from the SAML assertion / SCIM attributes your IdP sends (you also choose which attributes flow in the IdP’s attribute-mapping screen). After this, every session carries aws:PrincipalTag/team and aws:PrincipalTag/project.

Now write one policy that is parameterized by the tag. The classic pattern: engineers may manage only resources tagged with their own team.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ManageOwnTeamInstances",
      "Effect": "Allow",
      "Action": [
        "ec2:StartInstances",
        "ec2:StopInstances",
        "ec2:RebootInstances"
      ],
      "Resource": "arn:aws:ec2:*:*:instance/*",
      "Condition": {
        "StringEquals": {
          "aws:ResourceTag/team": "${aws:PrincipalTag/team}"
        }
      }
    },
    {
      "Sid": "EnforceTagOnCreate",
      "Effect": "Allow",
      "Action": "ec2:RunInstances",
      "Resource": "arn:aws:ec2:*:*:instance/*",
      "Condition": {
        "StringEquals": {
          "aws:RequestTag/team": "${aws:PrincipalTag/team}"
        }
      }
    }
  ]
}

The same PlatformAccess permission set, assigned to a group spanning every team, now self-segments: a user whose IdP department is payments can only touch team=payments resources, with zero per-team policy authoring. This is the single highest-leverage move in the whole system — it collapses N permission sets into one and makes the IdP the authority for what as well as who.

ABAC is only as trustworthy as your tagging discipline. If resources are not tagged, the condition denies (correctly) and engineers are locked out. Pair ABAC with an SCP or RunInstances-time tag-enforcement policy so resources cannot be created without the team tag. Untagged resources are the failure mode, not malicious users.

Step 5 — Scaling assignments with groups, automation, and least-privilege boundaries

Two non-negotiable rules at scale:

  1. Assign permission sets to groups, never to individual users. A user’s AWS access then changes the instant they join or leave a group in your IdP — no AWS ticket, no human in the loop. User-level assignments are how you end up with orphaned access nobody remembers granting.
  2. Express assignments as code. The set of (group, permission set, account) tuples is your access model; it belongs in version control with review, not in console clicks.

Creating an assignment by hand looks like this (note it provisions asynchronously):

# Resolve the group's principal id from the identity store.
GROUP_ID=$(aws identitystore get-group-id \
  --identity-store-id "$IDENTITY_STORE_ID" \
  --alternate-identifier 'UniqueAttribute={AttributePath=DisplayName,AttributeValue=platform-engineers}' \
  --query 'GroupId' --output text)

aws sso-admin create-account-assignment \
  --instance-arn "$SSO_INSTANCE_ARN" \
  --permission-set-arn "$PS_ARN" \
  --principal-type GROUP \
  --principal-id "$GROUP_ID" \
  --target-type AWS_ACCOUNT \
  --target-id 333333333333

For real fleets, drive this from Terraform so the model is reviewable and idempotent. The pattern below fans one permission set across many accounts for one group:

locals {
  platform_accounts = ["333333333333", "444444444444", "555555555555"]
}

data "aws_ssoadmin_instances" "this" {}

resource "aws_ssoadmin_account_assignment" "platform" {
  for_each = toset(local.platform_accounts)

  instance_arn       = tolist(data.aws_ssoadmin_instances.this.arns)[0]
  permission_set_arn = aws_ssoadmin_permission_set.platform_readonly.arn

  principal_id   = data.aws_identitystore_group.platform.group_id
  principal_type = "GROUP"

  target_id   = each.value
  target_type = "AWS_ACCOUNT"
}

Combine this with the permissions boundary from Step 3 on every non-admin permission set. The boundary guarantees that even if a referenced policy is later widened by mistake, the provisioned role cannot exceed the ceiling. SCPs cap the account, the boundary caps the role, the policies grant — the same layered model as the rest of your org.

Step 6 — CLI and credential workflows: aws sso login, profiles, short-lived sessions

Engineers do not live in the portal; they live in a terminal. The modern flow uses SSO token provider profiles in ~/.aws/config, which share one cached login across every account/role you use.

Bootstrap it once interactively:

aws configure sso
# Prompts for the SSO start URL, SSO Region, then lets you pick account + role.
# It writes an [sso-session] block plus a [profile ...] block.

The resulting config — note the sso-session block is shared, so a single aws sso login covers all profiles that reference it:

[sso-session kloudvin]
sso_start_url = https://kloudvin.awsapps.com/start
sso_region = us-east-1
sso_registration_scopes = sso:account:access

[profile platform-prod]
sso_session = kloudvin
sso_account_id = 333333333333
sso_role_name = PlatformReadOnly
region = us-east-1

[profile platform-admin]
sso_session = kloudvin
sso_account_id = 333333333333
sso_role_name = PlatformAdmin
region = us-east-1

Daily use:

# Authenticate once (opens a browser, device-code flow). Token is cached.
aws sso login --sso-session kloudvin

# Now every profile sharing that session just works, with short-lived creds.
aws sts get-caller-identity --profile platform-prod
aws s3 ls --profile platform-admin

# When done, drop the cached token.
aws sso logout

The credentials minted here are temporary STS credentials capped by the permission set’s session duration and refreshed transparently from the cached SSO token — there is nothing long-lived on disk to leak. For tools that cannot speak SSO natively, aws configure export-credentials --profile platform-prod emits temporary creds (still short-lived) you can feed to a subprocess, rather than baking keys.

Never paste export AWS_ACCESS_KEY_ID=... from a permission set into a dotfile. The entire point of Identity Center is that there is no static key. If a tool “needs keys,” reach for export-credentials (ephemeral) or a workload role — not an IAM user.

Step 7 — Auditing access: CloudTrail, access reports, and recertification

Access you cannot audit is access you do not control. Three layers:

1. CloudTrail. Sign-ins and role assumption surface as events. The federated role assumption shows up as AssumeRoleWithSAML (or the Identity Center-issued session), and the actual API calls in the member account carry the AWSReservedSSO_... role in userIdentity. Query it in Athena/CloudTrail Lake to answer “who used PlatformAdmin in account 333 last week”:

SELECT eventtime, useridentity.arn, eventname, sourceipaddress
FROM cloudtrail_logs
WHERE useridentity.arn LIKE '%AWSReservedSSO_PlatformAdmin%'
  AND eventtime > '2026-06-01T00:00:00Z'
ORDER BY eventtime DESC;

2. Identity Center access reports / last-accessed. Use the per-account-assignment and per-user views to find dormant access. The single most valuable signal is last-accessed: a group assigned a privileged permission set to an account that nobody has used in 90 days is access you should revoke.

3. Recertification. Because access is data (Step 5), recertification is a diff, not an investigation. Export the current assignments and review them on a cadence:

# Enumerate every permission set provisioned into an account, then who is assigned.
for PS in $(aws sso-admin list-permission-sets-provisioned-to-account \
      --instance-arn "$SSO_INSTANCE_ARN" --account-id 333333333333 \
      --query 'PermissionSets[]' --output text); do
  echo "== $PS =="
  aws sso-admin list-account-assignments \
    --instance-arn "$SSO_INSTANCE_ARN" \
    --account-id 333333333333 \
    --permission-set-arn "$PS" \
    --query 'AccountAssignments[].[PrincipalType,PrincipalId]' --output text
done

Feed that into the same Git-reviewed Terraform as your source of truth: anything in the live export that is not in code is drift to investigate; anything in code that a recertifier rejects is a PR to remove. Quarterly for privileged sets, semi-annually for read-only is a defensible baseline for most compliance regimes.

Verify

Concrete checks that the system actually behaves:

# 1. Instance + identity source resolve.
aws sso-admin list-instances --query 'Instances[0]'

# 2. SCIM actually populated groups (not empty).
aws identitystore list-groups --identity-store-id "$IDENTITY_STORE_ID" \
  --query 'length(Groups)'

# 3. The permission set provisioned into a target account (role exists).
aws iam list-roles --query "Roles[?starts_with(RoleName,'AWSReservedSSO_PlatformReadOnly')].RoleName" \
  --profile platform-prod

# 4. SSO login mints short-lived creds that work.
aws sso login --sso-session kloudvin
aws sts get-caller-identity --profile platform-prod

# 5. ABAC denies cross-team access (should FAIL for a payments user against a non-payments instance).
aws ec2 stop-instances --instance-ids i-0abc... --profile platform-prod

Expected: groups count is non-zero, the AWSReservedSSO_* role is present in the member account, get-caller-identity returns the assumed-role ARN, and the cross-team stop-instances is denied with an explicit authorization failure. If group count is zero, your SCIM token or scoping is wrong (Step 2). If the role is missing in the account, re-provision the permission set. If ABAC does not deny, confirm create-instance-access-control-attribute-configuration ran and the IdP is actually sending the mapped attribute.

Enterprise scenario

A platform team running ~120 accounts had drifted into per-team permission sets: DataEng-Prod, DataEng-Dev, Payments-Prod, and so on — 14 teams times 3 environments, north of 40 permission sets, each a near-copy of its neighbors. Every new team meant a fresh batch of sets and dozens of assignments, and the sets diverged over time because nobody edited all 40 consistently. The breaking constraint: an internal audit flagged that three of those sets still granted iam:* from a long-forgotten copy-paste, and the team could not prove the others were clean without reading 40 policies by hand.

They collapsed it to one EngineerStandard permission set plus ABAC. The IdP department attribute became a session tag, the per-team resource permissions became a single tag-conditioned policy (Step 4), and the environment split moved to account-level SCPs instead of separate sets. The decisive control was a permissions boundary on the consolidated set that made iam:* structurally impossible to grant, so the audit finding could never recur regardless of future policy edits:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "BoundaryAllowedServices",
      "Effect": "Allow",
      "Action": ["ec2:*", "s3:*", "dynamodb:*", "logs:*", "cloudwatch:*"],
      "Resource": "*"
    },
    {
      "Sid": "NeverIam",
      "Effect": "Deny",
      "Action": ["iam:*", "organizations:*", "account:*"],
      "Resource": "*"
    }
  ]
}

Outcome: 40-plus permission sets became 1, onboarding a new team became adding them to a group in the IdP (zero AWS changes), and the explicit Deny on iam:* in the boundary meant the audit finding was provably unrepeatable across all 120 accounts at once. Recertification dropped from a multi-day policy read to a single Terraform plan against the assignment model.

Checklist

awsiam-identity-centerssoabacidentity

Comments

Keep Reading