The situation
An org of forty AWS accounts, organised in a Control Tower landing zone, has two distinct cross-account access problems:
- Human engineers need shell and console access to dev, staging, and production accounts for their teams, scoped to their role (developer, tech lead, SRE, read-only auditor). They currently SSO to the landing zone via the IdP and then “role-hop” to other accounts, a pattern that has accumulated IAM users in a management account, long-lived access keys, and a manually maintained Jira spreadsheet of “who is in which account.”
- Programmatic callers cross account boundaries routinely: a deployment pipeline in the shared-tooling account deploys to the 40 product accounts; a monitoring Lambda in the security-audit account reads CloudWatch metrics from every other account; a third-party SaaS backup tool needs to list and copy S3 objects from each product account.
One team is pushing to “replace everything with Identity Center permission sets.” Another is pointing out that the backup SaaS doesn’t authenticate against Identity Center. The question is which pattern is correct for which case and how they coexist.
What actually matters
The two patterns are solving related but different problems. The shape of the correct answer depends on who’s calling and what they’re carrying.
A human caller has an identity managed somewhere (corporate IdP, Google Workspace, Okta, Azure AD). That identity should be the source of truth for “does this person exist, which teams are they on, have they left the company?” A cross-account access mechanism for humans needs to integrate with that IdP, provide a per-team-per-account federation, log every session to CloudTrail with the user’s identity, and expire automatically when the IdP deprovisions the user. The cost of failure is either “engineer can’t get into prod to debug an incident” (business cost) or “ex-employee still has access” (security cost).
A programmatic caller doesn’t have a human identity. It has a workload identity: a Lambda’s execution role, an EC2 instance profile, a pipeline’s service role, a third-party SaaS’s credentials. The caller needs to assume a role in the target account, scoped tightly to the specific API calls it makes, with an external ID or conditional key if the caller is outside the org. This is a cross-account IAM role. The human in this story is the engineer who configured the pipeline, not the caller at runtime.
Understanding that neither pattern replaces the other is the start. The two ways they relate: the SSO-based human pattern is cross-account role assumption under the covers. The SSO layer provisions a role in each target account and assumes it for the user, wrapping the underlying STS call in session management and a governance layer. So the question isn’t “roles vs SSO”; it’s “which kind of caller is at the wheel, and what wraps the role assumption for that caller.” Either way, the runtime credential should be temporary, scoped, and traceable. Long-lived static keys belong to the legacy category and should be migrated away from.
What we’ll filter on
- Caller type. Human or workload?
- Identity source. IdP, AWS organisations, or explicit trust?
- Session shape. Interactive, temporary, or programmatic?
- Scaling behaviour. One configuration for N accounts, or per-account plumbing?
- Auditability. Does the log reveal the human or just the role?
The access landscape
1. IAM Identity Center permission sets. The SSO-based pattern for humans. Permission sets are templates: a name, one or more managed or inline IAM policies, a session duration (1-12 hours). Assigned to users (or groups) paired with specific target AWS accounts. At login, the user sees the accounts and permission sets they’re allowed to use; picking one federates into that account with the permission set’s policy. Under the hood, Identity Center provisions and maintains a role in the target account (AWSReservedSSO_…). Accounts join/leave the org and permission sets apply automatically. Identities come from Identity Center’s own directory, or external IdPs (Okta, Azure AD, Google Workspace) via SAML 2.0 or SCIM.
2. Cross-account IAM roles. The low-level building block. A role in account B trusts a principal in account A (or a service, or a SAML IdP); the principal in A calls sts:AssumeRole on the role in B and gets temporary credentials for B. Trust policy determines who can assume; permission policy determines what they can do. External ID in the trust policy prevents the “confused deputy” problem when the trusting account is outside the org. Session duration 1-12 hours (default 1); credentials can be cached and reused within the window.
3. IAM roles for AWS services (execution roles, instance profiles). The intra-account analogue. An EC2 instance assumes the role via its instance profile; a Lambda assumes its execution role automatically. These are not themselves cross-account, but are often the source principal in a cross-account trust: “the Lambda’s execution role in account A assumes a role in account B.”
4. IAM users and long-lived access keys. The legacy pattern. Named user in IAM, static access keys, optionally MFA-bound policies. Strongly discouraged for new workloads. Still necessary for a narrow set of cases where no better mechanism exists (some third-party integrations that predate IAM Roles Anywhere).
5. IAM Roles Anywhere. For workloads outside AWS that need AWS credentials without pre-provisioned long-lived keys. Workloads present X.509 certificates from a trust anchor (an ACM Private CA); IAM Roles Anywhere issues temporary credentials. The “programmatic cross-account role” shape for non-AWS callers.
6. AWS Organizations SCPs. Not an access-granting mechanism but an access-limiting one. SCPs define the maximum permissions available in member accounts; they cut into what permission sets and IAM roles can actually do. The org-wide guardrail sitting above all of the above.
Side by side
| Option | Caller type | Identity source | Session shape | Scales to N accounts | Audit clarity |
|---|---|---|---|---|---|
| Identity Center permission sets | Human | IdP / IC directory | Interactive, 1-12h | ✓ (assign to account + set) | User + role in CT |
| Cross-account IAM role | Workload | STS AssumeRole trust | Programmatic, 1-12h | Per-role-per-account | Role + source principal |
| Service role / instance profile | Workload (in-account) | AWS service | Continuous | Per resource | Role |
| IAM user + access keys | Legacy | Directly IAM | Long-lived | Poorly | User |
| IAM Roles Anywhere | Workload (outside AWS) | X.509 cert | Temporary | Per-role | Role + cert CN |
| SCP | N/A (limit) | N/A | N/A | Org-wide | Deny in CT |
Reading the table: for every human, Identity Center. For every programmatic caller inside AWS, a cross-account IAM role (or an execution role if intra-account). For callers outside AWS that need temporary credentials, IAM Roles Anywhere. IAM users and keys are a legacy category to migrate away from.
The two patterns side by side
The picks in depth
Identity Center permission sets for humans. From the Organizations management account, enable IAM Identity Center, connect the corporate IdP via SAML and SCIM for auto-provisioning. Define permission sets to match the engineering job family: DeveloperFullAccess (broad but scoped to dev accounts), SREProductionOperator (read + remediate in production), ReadOnlyAuditor (read everywhere), PlatformAdministrator (admin in the shared-tooling account). Each permission set has one or more AWS-managed or customer-managed inline policies and a session duration (12 hours is the common choice for engineers; 1 hour for break-glass or highly privileged sets).
Assignment: “group X gets permission set Y on accounts Z1 and Z2.” Multi-account assignment via the console, CLI, or IaC (CloudFormation AWS::SSO::Assignment). When an engineer joins the IdP group, they get the matching account+permission-set assignments automatically. When they leave the group, access is revoked the next time Identity Center syncs (~10-30 minutes typical).
Under the hood, Identity Center provisions roles named AWSReservedSSO_<PermissionSetName>_<uniqueid> in each target account. These roles are managed by Identity Center; don’t modify them manually. The assumption process uses STS internally; CloudTrail in the target account shows the role assumption with the user’s name in the session name, preserving the human identity for audit.
Cross-account IAM roles for programmatic access. For the deployment pipeline in the shared-tooling account deploying to 40 product accounts: one role per product account named DeploymentRole, with a trust policy allowing the pipeline’s service role in the tooling account. Permission policy scoped to the exact actions the pipeline needs (CloudFormation deploy, ECS update, specific S3 paths). The pipeline calls sts:AssumeRole at the start of the deploy, caches the temporary credentials for the session duration, and uses them for the deploy’s API calls. Rotation is automatic because credentials live for the session duration.
For the monitoring Lambda reading metrics from every account: the Lambda’s execution role in the security account is the trusted principal in a cross-account role (MonitoringReader) in each member account. Trust policy: allow sts:AssumeRole from the specific Lambda execution role ARN, with a condition on aws:SourceAccount. Permission policy: CloudWatch read only, scoped to specific metric namespaces.
For the third-party SaaS backup tool: a role in each product account trusting the SaaS’s AWS account ID, with an External ID condition set to a value the SaaS provides. The External ID is the mitigation for the “confused deputy” problem; without it, any customer of the same SaaS could potentially assume the role if the SaaS’s account is known. SaaS documentation should specify the role trust policy; never grant trust to a third-party account without an External ID.
Organisation-wide trust via Resource Access Manager (where appropriate). For shared resources (a central VPC, a central Transit Gateway, a centralised KMS key), RAM shares the resource across accounts without each consuming account needing an IAM role into the producer account. This is the “shared infrastructure” pattern; it doesn’t replace IAM roles for API access, but it reduces the need for cross-account roles on resources that can be shared directly.
A worked permission-set deployment
The org’s SRE team needs SREProductionOperator access on the 12 production accounts. The permission set has:
- Managed policies:
ReadOnlyAccess(full read),AmazonSSMAutomationRole(run remediation runbooks). - Inline policy: allow
ec2:RebootInstances,autoscaling:SetDesiredCapacity,rds:RebootDBInstance, scoped to resources taggedEnvironment=production. - Session duration: 1 hour (tight for privileged access).
- Relay state: the CloudWatch console, so the portal opens directly to metrics.
Assignment: IdP group sre gets SREProductionOperator on all 12 production accounts. CloudFormation:
Type: AWS::SSO::Assignment
Properties:
InstanceArn: arn:aws:sso:::instance/ssoins-…
TargetType: AWS_ACCOUNT
TargetId: "111122223333" # one per account
PermissionSetArn: arn:aws:sso:::permissionSet/…/PermissionSet-…
PrincipalType: GROUP
PrincipalId: "<group-id-from-sso>"
Deploy via pipeline; IaC repo holds one of these per account. An SRE joining the team gets production access when Okta syncs sre group membership; an SRE leaving loses it on the next sync. Zero manual IAM work in any of the 12 accounts.
A worked cross-account role
The deployment pipeline’s per-account DeploymentRole:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::tooling-account-id:role/codebuild-deploy" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "deploy-acme-v1",
"aws:SourceAccount": "tooling-account-id"
}
}
}]
}
Permission policy: just the deploy actions, nothing else. The pipeline’s code:
creds = sts.assume_role(
RoleArn=f"arn:aws:iam::{target_account}:role/DeploymentRole",
RoleSessionName=f"deploy-{commit_sha}",
ExternalId="deploy-acme-v1"
)
CloudTrail in the target account logs sts:AssumeRole by arn:aws:iam::tooling:role/codebuild-deploy → arn:aws:iam::target:role/DeploymentRole with the session name deploy-<sha>. Every subsequent API call the pipeline makes during the deploy carries that session, so the audit trail links every resource change back to the specific commit.
What’s worth remembering
- Identity Center permission sets are for humans. Assignments are (group × account × permission set); access follows IdP group membership.
- Permission sets are IAM roles under the covers. The
AWSReservedSSO_…roles in each target account are managed by Identity Center; don’t modify them manually. - Cross-account IAM roles are for programmatic access. Pipelines, Lambdas, SaaS integrations. Trust policy declares who can assume; permission policy declares what they can do.
- External ID prevents confused-deputy when the trusting account is outside the org. Always required for third-party SaaS integrations.
- IAM users and long-lived keys are legacy. Migrate to permission sets for humans, cross-account roles for workloads, Roles Anywhere for off-AWS workloads.
- Session duration is a security lever. Short sessions for privileged access (1 hour); longer for day-to-day developer use (12 hours max).
- SCPs are the org-wide guardrail. They define the ceiling; permission sets and IAM role permissions can only do what SCPs permit.
- CloudTrail audit differs by pattern. Identity Center sessions preserve the user’s name in session metadata; cross-account role assumptions show the source role and session name, which the pipeline should set meaningfully.
Two patterns, two callers, one principle: the credential at runtime should be temporary, scoped, and traceable to the entity that legitimately needs it. Permission sets do that for humans; cross-account IAM roles do it for everything else.