JSTGTECH
← Back to blog

IAM Identity Center: kill per-account IAM users for good

8 min read

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-set triggered automatically by terraform apply for aws_ssoadmin_permission_set changes — 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_group data 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_assignment state entries hang around if the group was deleted out-of-band from Terraform. terraform plan will 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 AssumeRole into with a custom DurationSeconds up to the role’s max, the permission set’s session_duration is 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 AdministratorAccess in 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-identity and CloudTrail both show the assumed-role session name, which Identity Center populates with the actual user’s identity — so AssumedRole events 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 login are 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.

Related posts