IAM Identity Center: kill per-account IAM users for good
If you’re running more than one or two AWS accounts, you’ve probably got the
same mess I inherited: a pile of IAM users, one per human, duplicated in
every account they need access to, each with its own password, its own MFA
device, and — if you’re unlucky — its own long-lived access keys sitting in
someone’s ~/.aws/credentials. Nobody remembers to deprovision the ex-
contractor’s user in the staging account. Nobody’s rotated the access keys
on the prod-readonly user since 2023. IAM Identity Center (the renamed AWS
SSO service) fixes this by moving identity out of individual accounts
entirely: you log in once, in one place, and get temporary credentials into
whichever accounts and roles you’re assigned to. This is the setup I run
across a management account plus workload accounts, with the actual
Terraform and the parts that don’t work the way the docs imply.
What Identity Center actually replaces
Per-account IAM users have three structural problems: identity is duplicated per account (N users × M accounts to manage), credentials are usually long-lived (passwords, access keys), and there’s no single place to see “what can this person get into.” IAM Identity Center centralizes all of that:
- One identity source — either Identity Center’s own built-in directory, or federated from an external IdP (Okta, Entra ID/Azure AD, Google Workspace, or on-prem AD via AD Connector) over SCIM.
- Permission sets — reusable IAM policy bundles (think “IAM role templates”) that get provisioned as actual IAM roles in target accounts when you assign them.
- Account assignments — a mapping of (user or group) × (permission set) × (account), managed centrally from the Identity Center console/API in your management account or a delegated administrator account.
The end state: a human logs into the Identity Center portal URL once, sees a tile for every account/role combination they’ve been granted, clicks one, and gets temporary STS credentials. No IAM user, no password in that account, no access key to leak.
Setting it up
Identity Center is enabled once per AWS Organization, in one region (it’s
free-tier, but the instance and its permission sets live in a single home
region even though assignments apply org-wide). I run mine out of
us-east-1 in the management account, alongside the org’s other org-wide
resources.
1. Choose your identity source
If you already run Okta or Entra ID, use it — don’t stand up a second identity directory to maintain. Enable SCIM provisioning in Identity Center (Settings → Identity source → Automatic provisioning), which gives you an SCIM endpoint URL and a bearer token. Paste those into your IdP’s SCIM app config, and your IdP becomes the source of truth for users and groups; Identity Center just mirrors them.
If you don’t have an external IdP yet, the built-in Identity Center directory is fine to start with — you can migrate to an external IdP later without re-doing your permission sets or assignments, since those reference group IDs, not the identity source itself.
2. Create permission sets
A permission set is either a collection of AWS managed policies, a custom
inline policy, or both, plus a session duration. I manage mine in Terraform
using the aws_ssoadmin_permission_set resource — this is the piece people
skip because the console makes it feel like a one-off, and then six months
later nobody remembers what’s in the Billing-ReadOnly permission set or
why.
data "aws_ssoadmin_instances" "this" {}
resource "aws_ssoadmin_permission_set" "read_only" {
name = "ReadOnlyAccess"
instance_arn = tolist(data.aws_ssoadmin_instances.this.arns)[0]
session_duration = "PT4H"
}
resource "aws_ssoadmin_managed_policy_attachment" "read_only" {
instance_arn = tolist(data.aws_ssoadmin_instances.this.arns)[0]
permission_set_arn = aws_ssoadmin_permission_set.read_only.arn
managed_policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}
resource "aws_ssoadmin_permission_set" "billing_admin" {
name = "BillingAdmin"
instance_arn = tolist(data.aws_ssoadmin_instances.this.arns)[0]
session_duration = "PT1H"
}
resource "aws_ssoadmin_permission_set_inline_policy" "billing_admin" {
instance_arn = tolist(data.aws_ssoadmin_instances.this.arns)[0]
permission_set_arn = aws_ssoadmin_permission_set.billing_admin.arn
inline_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["aws-portal:*Billing", "aws-portal:*Usage", "ce:*"]
Resource = "*"
}]
})
}
session_duration is an ISO 8601 duration, and it’s the actual cap on
credential lifetime for anyone assigned this permission set — not
overridable at assumption time. I keep BillingAdmin at PT1H and
ReadOnlyAccess at PT4H since read-only browsing sessions are lower risk
than anything that touches spend controls.
How permission sets map to account assignments
This is the part that trips people up coming from single-account IAM
thinking: a permission set is a template, not a role. Nothing exists in a
member account until you create an account assignment — at that point
Identity Center provisions an actual IAM role in the target account, named
AWSReservedSSO_<permission-set-name>_<hash>, with a trust policy that
allows the Identity Center service to assume it. You’ll see these roles
show up in IAM → Roles in every account you’ve assigned; don’t hand-edit
them, they’re managed by Identity Center and your edits will get silently
reverted on the next provisioning sync.
Assignment is a three-way link — group, permission set, account:
resource "aws_ssoadmin_account_assignment" "readonly_staging" {
instance_arn = tolist(data.aws_ssoadmin_instances.this.arns)[0]
permission_set_arn = aws_ssoadmin_permission_set.read_only.arn
principal_id = data.aws_identitystore_group.engineers.group_id
principal_type = "GROUP"
target_id = "222233334444" # staging account
target_type = "AWS_ACCOUNT"
}
Walkthrough: one group, three accounts
Say I want the Engineers group to have ReadOnlyAccess in dev,
staging, and prod, but a separate PowerUser permission set only in
dev. First, look up the group by its SCIM-synced display name:
data "aws_identitystore_group" "engineers" {
identity_store_id = tolist(data.aws_ssoadmin_instances.this.identity_store_ids)[0]
alternate_identifier {
unique_attribute {
attribute_path = "DisplayName"
attribute_value = "Engineers"
}
}
}
locals {
readonly_accounts = {
dev = "111122223333"
staging = "222233334444"
prod = "333344445555"
}
}
resource "aws_ssoadmin_account_assignment" "readonly" {
for_each = local.readonly_accounts
instance_arn = tolist(data.aws_ssoadmin_instances.this.arns)[0]
permission_set_arn = aws_ssoadmin_permission_set.read_only.arn
principal_id = data.aws_identitystore_group.engineers.group_id
principal_type = "GROUP"
target_id = each.value
target_type = "AWS_ACCOUNT"
}
resource "aws_ssoadmin_account_assignment" "poweruser_dev" {
instance_arn = tolist(data.aws_ssoadmin_instances.this.arns)[0]
permission_set_arn = aws_ssoadmin_permission_set.power_user.arn
principal_id = data.aws_identitystore_group.engineers.group_id
principal_type = "GROUP"
target_id = local.readonly_accounts.dev
target_type = "AWS_ACCOUNT"
}
Apply that and everyone in Engineers sees three tiles for ReadOnlyAccess
(one per account) and one extra tile for PowerUser in dev, in the
Identity Center portal. If you’d rather click through the console: Identity
Center → Permission sets → select one → nothing happens there for
assignment; you actually go to Multi-account permissions → select
account(s) → select the permission set → select the group. It’s easy to
land on the wrong page first time because assignment lives under the
account list, not the permission set page.
CLI usage: aws sso login
Once assigned, configure a named profile pointing at the SSO session:
# ~/.aws/config
[sso-session my-org]
sso_start_url = https://my-org.awsapps.com/start
sso_region = us-east-1
sso_registration_scopes = sso:account:access
[profile staging-readonly]
sso_session = my-org
sso_account_id = 222233334444
sso_role_name = ReadOnlyAccess
region = us-east-1
aws sso login --profile staging-readonly
aws sts get-caller-identity --profile staging-readonly
aws sso login opens a browser, you authenticate against your IdP (or the
built-in directory), and the CLI caches temporary credentials locally,
scoped to the permission set’s session_duration. When they expire, you
just re-run aws sso login — no key rotation, nothing to revoke in the
account itself, because there was never a long-lived credential there to
begin with.
Gotchas learned the hard way
- Permission set edits don’t auto-propagate. If you change a permission
set’s policy (add a managed policy, edit the inline JSON), existing
account assignments keep the old IAM role permissions until you
re-provision. In the console this is the “Reprovision” button on the
permission set’s Accounts tab; via API/Terraform it’s
aws sso-admin provision-permission-settriggered automatically byterraform applyforaws_ssoadmin_permission_setchanges — but if you’re editing an inline policy attached via a separate resource, double check the apply actually touched the permission set resource itself, not just the attachment, or the role in the target account can silently stay stale for hours. - SCIM sync lag. A new group or user added in your IdP doesn’t appear
in Identity Center instantly — SCIM sync typically runs every 15-40
minutes depending on the IdP. If you just created a group in Okta and
your
aws_identitystore_groupdata source in Terraform can’t find it, that’s not a Terraform bug, it’s sync lag. Check Identity Center → Settings → Automatic provisioning for the last sync timestamp before you start debugging your HCL. - Deleting a group in the IdP doesn’t clean up assignments. SCIM
deprovisioning removes the user/group from the Identity Store, but I’ve
seen orphaned
aws_ssoadmin_account_assignmentstate entries hang around if the group was deleted out-of-band from Terraform.terraform planwill complain it can’t find the referenced group ID. Clean up assignments in code before deleting the group upstream, not after. - Session duration is a hard cap, not a suggestion. Unlike an IAM role
you can
AssumeRoleinto with a customDurationSecondsup to the role’s max, the permission set’ssession_durationis what you get — there’s no per-login override. If your CI needs longer sessions than your interactive permission set allows, that’s a sign CI shouldn’t be using an Identity Center human-login flow at all; use a workload identity (OIDC role assumption, like I covered in the GitHub Actions post) instead. - The management account itself is a special case. You generally don’t
want broad permission sets assigned in the Organizations management
account — keep assignments there to a small break-glass set, and do real
work through member/workload accounts. It’s easy to accidentally grant
AdministratorAccessin the management account because it’s first in the account picker.
Trade-offs vs per-account IAM users
- Single point of login, single point of failure. If Identity Center or your IdP has an outage, nobody can get interactive access to any account through SSO. This is why you keep a small number of emergency IAM users (with hardware MFA, credentials in a sealed vault process, not routine use) for break-glass — Identity Center replaces routine human access, not disaster recovery access.
- No more per-account password/MFA sprawl, but you’ve now got a much bigger blast radius on the identity source itself — securing your IdP (and its MFA policy) matters more than it used to, because it’s now the gate to every account at once.
- Auditing gets dramatically better.
aws sts get-caller-identityand CloudTrail both show the assumed-role session name, which Identity Center populates with the actual user’s identity — soAssumedRoleevents in CloudTrail are attributable to a person, not a shared IAM user ARN that three people know the password to. - Programmatic/service access still isn’t Identity Center’s job.
Permission sets and
aws sso loginare for humans. Machine-to-machine auth (CI/CD, Lambda, EC2) should stay on IAM roles with OIDC federation or instance profiles — don’t try to shoehorn a service account into an Identity Center permission set just to avoid having two auth patterns in your org.
Rolling it out
Don’t flip every account to Identity Center-only in one PR. Stand up
Identity Center, get SCIM sync working, create permission sets, and assign
them in one low-risk account (I used a scratch sandbox account) first —
confirm people can actually log in via the portal and get the access you
expect. Then extend account assignments outward account by account, and
only after everyone’s confirmed they can get in through SSO do you go back
and deprovision the old per-account IAM users and their access keys. Keep a
short overlap window rather than a hard cutover date; the failure mode of
“we deleted the IAM user before confirming the SSO group assignment
actually worked” is a locked-out engineer and an emergency root login, not
a fun afternoon.
Join the discussion
Comments for this post live on social — reply to the thread.
Related posts
Cloud roundup: macOS Screen Sharing bug now under attack
A patched macOS Screen Sharing flaw is being exploited to plant crypto miners, a Windows Defender bypass has no fix yet, and EC2 gets built-in app health checks.
Cutting NAT gateway costs with VPC endpoints that actually help
How gateway and interface VPC endpoints replace NAT gateway traffic for AWS API calls, what they cost instead, and which traffic still has to go through NAT.
Cloud roundup: S3 finally names the policy that denied you
AWS S3 access-denied errors now name the exact policy ARN, Client VPN gets a scriptable CLI, and OpenAI ships authorized offensive-security models on Bedrock.