AWS IAM Roles and Policies
Trust policies versus permission policies, assuming roles with STS, managed and inline policies, permissions boundaries, and how to debug an assume-role failure.
A role is an identity with no permanent credentials. Something assumes it, receives temporary credentials, and acts with its permissions until they expire.
That is why roles are preferred almost everywhere: there is no lasting secret to leak, rotate, or find committed to a repository.
Every role has two policies
This trips up nearly everyone at first, and keeping them straight makes IAM errors far easier to read.
The trust policy: who may assume this role:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "ec2.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}
The permissions policy: what the role can do once assumed:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::acme-artifacts/*"
}]
}
The error message tells you which one failed:
| Denied on | Problem |
|---|---|
sts:AssumeRole | Trust policy (you are not permitted to assume it) |
| Any other action | Permissions policy: you assumed it but it cannot do that |
Principals in a trust policy
// An AWS service
"Principal": { "Service": "lambda.amazonaws.com" }
// A specific role in another account
"Principal": { "AWS": "arn:aws:iam::444455556666:role/CIDeployer" }
// A whole account. Broader, and means anyone in it with the right IAM
"Principal": { "AWS": "arn:aws:iam::444455556666:root" }
// A federated identity provider, e.g. GitHub Actions via OIDC
"Principal": { "Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com" }
Naming :root means "any principal in that account whose own IAM policy allows it", not the root user. It is broader than most people intend; naming a specific role is better.
Conditions narrow it considerably, and for cross-account access an external ID protects against the confused deputy problem:
{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::444455556666:root" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "sts:ExternalId": "acme-7f3c1e2a" }
}
}
For OIDC federation, the condition is what stops any repository on the provider assuming your role:
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:acme/payments:ref:refs/heads/main"
}
}
Omitting that sub condition is a serious mistake, without it, any GitHub repository anywhere can assume the role.
Assuming a role
aws sts assume-role \
--role-arn arn:aws:iam::111122223333:role/DeployRole \
--role-session-name ci-build-482
{
"Credentials": {
"AccessKeyId": "ASIA...",
"SecretAccessKey": "...",
"SessionToken": "...",
"Expiration": "2026-09-12T12:30:00Z"
}
}
--role-session-name appears in CloudTrail, so putting a build number or username there turns an anonymous session into an auditable one. Use it meaningfully.
Sessions default to one hour and can be extended up to the role's maximum session duration, which itself caps at twelve hours. Role chaining, assuming a role from within an assumed role, is limited to one hour regardless.
In practice you rarely call assume-role by hand. A named profile does it for you:
# ~/.aws/config
[profile prod-deploy]
role_arn = arn:aws:iam::111122223333:role/DeployRole
source_profile = default
mfa_serial = arn:aws:iam::111122223333:mfa/alice
aws s3 ls --profile prod-deploy
Policy types
| Type | Attached to | Notes |
|---|---|---|
| AWS managed | Principals | Maintained by AWS, generally too broad |
| Customer managed | Principals | Yours, reusable, versioned (prefer these) |
| Inline | One principal | Invisible in policy listings, not reusable |
| Resource-based | A resource | S3 bucket policies, KMS key policies, SQS queue policies |
| Permissions boundary | A principal | A ceiling, never a grant |
| Service control policy | An Organization OU | A ceiling across accounts |
Prefer customer-managed policies to inline ones. An inline policy does not appear when you list policies, cannot be attached elsewhere, and is easy to miss in an audit. AWS managed policies are convenient starting points and almost always grant more than needed, AmazonS3FullAccess on a role that reads one prefix is a real risk with no benefit.
Customer-managed policies are versioned, with up to five versions kept, so rolling back a bad change is straightforward:
aws iam list-policy-versions --policy-arn arn:aws:iam::111122223333:policy/PaymentsRead
aws iam set-default-policy-version --policy-arn <arn> --version-id v3
Permissions boundaries
A boundary sets the maximum permissions a principal can have. The effective permissions are the intersection of its policies and its boundary. A boundary never grants anything.
The main use is safe delegation: letting a team create roles for their own workloads without being able to escalate to administrator.
{
"Effect": "Allow",
"Action": "iam:CreateRole",
"Resource": "*",
"Condition": {
"StringEquals": {
"iam:PermissionsBoundary": "arn:aws:iam::111122223333:policy/TeamBoundary"
}
}
}
That condition means they may create roles only if they attach your boundary, so anything they create is capped by it.
This is also a common cause of confusing denials. A principal with AdministratorAccess and a restrictive boundary is not an administrator, and nothing in the error says so.
How workloads get roles
| Compute | Mechanism |
|---|---|
| EC2 | Instance profile |
| Lambda | Execution role |
| ECS | Task role for your code; task execution role is for pulling images and writing logs |
| EKS | Pod Identity, or IRSA |
| CodeBuild | Service role |
| GitHub Actions / GitLab CI | OIDC federation |
The ECS distinction is worth stating clearly because it causes a lot of confusion: the task execution role is used by the ECS agent to pull the image from ECR and write logs; the task role is what your application code uses to call AWS. Putting your application's S3 permissions on the execution role does not work.
The SDK finds these credentials automatically through the default provider chain, so application code should never reference them explicitly.
Debugging
# Who am I actually?
aws sts get-caller-identity
# Would this be allowed?
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::111122223333:role/DeployRole \
--action-names s3:PutObject \
--resource-arns 'arn:aws:s3:::acme-artifacts/build.zip'
# What is attached?
aws iam list-attached-role-policies --role-name DeployRole
aws iam list-role-policies --role-name DeployRole # inline ones
aws iam get-role --role-name DeployRole --query 'Role.AssumeRolePolicyDocument'
Run both list-attached-role-policies and list-role-policies. The second returns inline policies, which the first does not, and an unnoticed inline policy is a classic source of "where is this permission coming from".
Common causes, in the order worth checking:
| Symptom | Cause |
|---|---|
Denied on sts:AssumeRole | Trust policy does not name your principal, or a condition is unmet |
| Denied on the action after assuming | Permissions policy is missing it |
AdministratorAccess still denied | An SCP or permissions boundary is capping it |
| Works for you, not for CI | Different principal (check get-caller-identity in the pipeline) |
| Cross-account fails | Both sides must allow it; check the external ID condition |
Quick reference
| Task | Command |
|---|---|
| Who am I | aws sts get-caller-identity |
| Assume a role | aws sts assume-role --role-arn <arn> --role-session-name <name> |
| Trust policy | aws iam get-role --role-name <n> --query 'Role.AssumeRolePolicyDocument' |
| Attached policies | aws iam list-attached-role-policies --role-name <n> |
| Inline policies | aws iam list-role-policies --role-name <n> |
| Simulate | aws iam simulate-principal-policy --policy-source-arn <arn> --action-names <action> |
| Policy versions | aws iam list-policy-versions --policy-arn <arn> |
| Unused permissions | aws iam generate-service-last-accessed-details --arn <arn> |
Key takeaways
- Two policies per role. Trust decides who may assume it; permissions decide what it can do.
- The denied action tells you which one failed:
sts:AssumeRolemeans trust. :rootas a principal means the whole account, which is broader than most people intend.- OIDC trust policies must condition on
sub, or any repository can assume the role. - Prefer customer-managed policies. Inline ones are invisible in listings and unreusable.
- A permissions boundary is a ceiling, never a grant: and a silent cause of denials.
- ECS task role is for your code; task execution role is for the agent. They are not interchangeable.
- Put something meaningful in
--role-session-nameso CloudTrail is useful.
Check your understanding
An OIDC trust policy that is too open
A GitHub Actions OIDC trust policy conditions only on the audience claim, not on sub. What is the consequence?
ECS task cannot reach S3
An ECS task pulls its image and writes logs successfully, but the application gets AccessDenied calling S3. S3 permissions were added to the task execution role. What is wrong?
Frequently Asked Questions
What is the difference between a trust policy and a permissions policy?
Both attach to a role and answer different questions. The trust policy names which principals are allowed to assume the role: an AWS service, another account, a federated identity provider. The permissions policy defines what the role can do once assumed. When you get AccessDenied, the action named in the error tells you which to look at: a denial on sts:AssumeRole is a trust policy problem, while a denial on anything else means you assumed the role successfully and its permissions are lacking.
How do I assume an IAM role from the CLI?
Either explicitly with aws sts assume-role --role-arn <arn> --role-session-name <name>, which returns temporary credentials, or, much more conveniently, by configuring a named profile in ~/.aws/config with role_arn and source_profile, after which aws s3 ls --profile prod-deploy handles the assumption transparently. Adding mfa_serial to the profile prompts for an MFA code automatically. Always set a meaningful session name, since it appears in CloudTrail and is what makes role activity attributable to a person or a build.
What is the difference between managed and inline policies?
A managed policy is a standalone object that can be attached to many principals, is versioned so changes can be rolled back, and appears when you list policies. An inline policy is embedded in a single principal, cannot be reused, has no version history, and does not show up in policy listings, which makes it easy to miss during an audit and a frequent source of unexplained permissions. Prefer customer-managed policies. AWS managed policies are useful starting points but are generally far broader than a specific workload needs.
What is a permissions boundary and when do I need one?
A permissions boundary caps the maximum permissions a principal can have; effective permissions are the intersection of its policies and the boundary. It never grants anything. The main use is safe delegation. Allowing a team to create IAM roles for their own workloads while requiring that every role they create carries your boundary, so nothing they create can exceed it. It is also a common cause of confusing denials, since a principal with AdministratorAccess and a restrictive boundary is not an administrator and the error message does not mention the boundary.
Why does my ECS task get AccessDenied when calling AWS APIs?
Almost certainly because the permissions are on the wrong role. ECS uses two: the task execution role, assumed by the ECS agent to pull images from ECR and write logs to CloudWatch, and the task role, which your application code uses via the SDK. If image pulls and logging work but your application's API calls fail, the permissions were added to the execution role and need to be on the task role instead.
How do I set up cross-account role access safely?
Name the specific role in the other account rather than using :root, which grants to any principal in that account whose own IAM permits it. Add an external ID condition when a third party will assume the role, which protects against the confused deputy problem where another customer of that third party could otherwise have them assume your role. Remember both sides must allow it, the trust policy on your role and an IAM policy in the calling account, and if the target resource uses KMS encryption, the key policy needs the external principal as well.