Roles and STS
A role is how AWS gives an identity to something that should never hold a long-lived password: a CI job, an EC2 instance, a pod, an engineer jumping into the production account, a vendor's system. Instead of keys, whoever is allowed to assume the role gets temporary credentials from STS (Security Token Service) that expire on their own. Once roles click, most of AWS security makes sense; when they do not, you get the most confusing AccessDenied of all.
Need to know: a role has two kinds of policy. The trust policy (a resource-based policy on the role) says who may assume it; the permissions policies say what the role may do. aws sts assume-role returns an access key starting with ASIA, a secret, a session token and an expiry (1 hour by default, up to the role's MaxSessionDuration, at most 12 hours). The session's identity is arn:aws:sts::<account>:assumed-role/<role>/<session name>. If the trust policy trusts the account (:root), the caller's own policies must also allow sts:AssumeRole.
A role, end to end
$ cd ~/oncall-lab/labs/aws
$ cat try/trust-learner.json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::111122223333:user/learner"
},
"Action": "sts:AssumeRole"
}
]
}
$ aws iam create-role --role-name try-ops-readonly --assume-role-policy-document file://try/trust-learner.json --query Role.Arn --output text
arn:aws:iam::111122223333:role/try-ops-readonly
$ aws iam attach-role-policy --role-name try-ops-readonly --policy-arn arn:aws:iam::aws:policy/ReadOnlyAccess
$ aws sts assume-role --role-arn arn:aws:iam::111122223333:role/try-ops-readonly --role-session-name learner-readonly
{
"Credentials": {
"AccessKeyId": "ASIAITJDPRSMLUJDKKSX",
"SecretAccessKey": "phD87dyiTE+b206talSf2RiEdhFO5LzQHCql3K9x",
"SessionToken": "IQoJb3JpZ2luX2VjElubtajuYq6mdZMk+IZeHJgPcX5XuQf2CCZAk0+r0LRSauVDr9XNpe/xoF0iu//////////wEaDGV1LWNlbnRyYWwtMSJHMEUCIQUnmpsOFiYxcL8BkI6dzGpV5X+mL0SFBKYbGmnz9ABKQWoWkjBa1HCH4hr3RqrJShFmRcRQ1kIdx84pqrX3b0DHPoPdx2veS2wrS5U1foZujBCm2LA9WwytK7==",
"Expiration": "2026-09-22T21:00:04+00:00"
},
"AssumedRoleUser": {
"AssumedRoleId": "AROAXHWZWVVFTZO22P2RU:learner-readonly",
"Arn": "arn:aws:sts::111122223333:assumed-role/try-ops-readonly/learner-readonly"
}
}
The response is three credentials and an identity. The AccessKeyId starts with ASIA (temporary) instead of AKIA, and the SessionToken is mandatory: without it the key ID and secret are rejected. To use them, put all three in the environment - the second step of the credential chain:
$ eval $(aws sts assume-role --role-arn arn:aws:iam::111122223333:role/try-ops-readonly --role-session-name learner-readonly --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text | awk '{print "export AWS_ACCESS_KEY_ID="$1" AWS_SECRET_ACCESS_KEY="$2" AWS_SESSION_TOKEN="$3}')
$ aws sts get-caller-identity
{
"UserId": "AROAXHWZWVVFTZO22P2RU:learner-readonly",
"Account": "111122223333",
"Arn": "arn:aws:sts::111122223333:assumed-role/try-ops-readonly/learner-readonly"
}
$ aws iam create-user --user-name try-should-not-work
aws: [ERROR]: An error occurred (AccessDenied) when calling the CreateUser operation: User: arn:aws:sts::111122223333:assumed-role/try-ops-readonly/learner-readonly is not authorized to perform: iam:CreateUser on resource: arn:aws:iam::111122223333:user/try-should-not-work because no identity-based policy allows the iam:CreateUser action
$ unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
$ aws sts get-caller-identity --query Arn --output text
arn:aws:iam::111122223333:user/learner
While the variables were set, you were the role: read-only, so CreateUser was denied - and the message named the session (assumed-role/try-ops-readonly/learner-readonly), which is how you spot "this ran as a role, not as me". The UserId is the role's unique ID (AROA...) plus the session name; CloudTrail records both, so pick session names that identify the human or the job (build-4711, ana, not session1).
The CLI can do it for you: role profiles
Exporting variables gets old, and the credentials expire in an hour. A profile with role_arn and source_profile makes the CLI assume the role itself, cache the credentials in ~/.aws/cli/cache/ and refresh them when they expire:
$ aws configure set role_arn arn:aws:iam::111122223333:role/try-ops-readonly --profile try-ops
$ aws configure set source_profile default --profile try-ops
$ aws configure set role_session_name learner --profile try-ops
$ tail -4 ~/.aws/config
[profile try-ops]
role_arn = arn:aws:iam::111122223333:role/try-ops-readonly
source_profile = default
role_session_name = learner
$ aws sts get-caller-identity --profile try-ops --query Arn --output text
arn:aws:sts::111122223333:assumed-role/try-ops-readonly/learner
$ aws configure list --profile try-ops
NAME : VALUE : TYPE : LOCATION
profile : try-ops : None : --profile
access_key : ****************ZTCR : assume-role :
secret_key : ****************317A : assume-role :
region : <not set> : None : None
$ ls ~/.aws/cli/cache/
a6213520af19004553a334662335f7e688637c3a.json
source_profile says which credentials sign the AssumeRole call; the TYPE column now says assume-role. This is how people move between accounts: one set of base credentials (better: an SSO session), one profile per role per account.
Trust: the principal, or the whole account?
A trust policy's Principal can name the user or role directly, or the account (arn:aws:iam::111122223333:root, which does not mean the root user: it means "principals of this account"). The difference is who else must agree:
- a principal named directly: the trust policy alone is enough - the caller needs no
sts:AssumeRolepermission of its own; - the account: the trust policy delegates the decision to IAM, so the caller's identity-based policies must also allow
sts:AssumeRoleon the role.
The lab has a break-glass role (try-break-glass, created from try/trust-account.json) that trusts the whole account:
$ aws iam get-role --role-name try-break-glass --query Role.AssumeRolePolicyDocument.Statement
[
{
"Sid": "AnyoneInTheAccountWhoIsAllowed",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::111122223333:root"
},
"Action": "sts:AssumeRole"
}
]
$ aws sts assume-role --role-arn arn:aws:iam::111122223333:role/try-break-glass --role-session-name test --profile try-ops
aws: [ERROR]: An error occurred (AccessDenied) when calling the AssumeRole operation: User: arn:aws:sts::111122223333:assumed-role/try-ops-readonly/learner is not authorized to perform: sts:AssumeRole on resource: arn:aws:iam::111122223333:role/try-break-glass because no identity-based policy allows the sts:AssumeRole action
try-ops is a session of a read-only role: its policies do not allow sts:AssumeRole, so the account-wide trust did not help it. The message says exactly that - "because no identity-based policy allows". If the trust policy did not match at all you would read "because no role trust policy allows the sts:AssumeRole action".
Limits that bite
$ aws sts assume-role --role-arn arn:aws:iam::111122223333:role/try-ops-readonly --role-session-name long --duration-seconds 14400
aws: [ERROR]: An error occurred (ValidationError) when calling the AssumeRole operation: The requested DurationSeconds exceeds the MaxSessionDuration set for this role.
$ aws iam update-role --role-name try-ops-readonly --max-session-duration 14400
$ aws sts assume-role --role-arn arn:aws:iam::111122223333:role/try-ops-readonly --role-session-name long --duration-seconds 14400 --query Credentials.Expiration --output text
2026-09-23T00:00:06+00:00
$ aws sts assume-role --role-arn arn:aws:iam::111122223333:role/try-break-glass --role-session-name chained --duration-seconds 7200 --profile try-ops
aws: [ERROR]: An error occurred (ValidationError) when calling the AssumeRole operation: The requested DurationSeconds exceeds the 1 hour session limit for roles assumed by role chaining.
- MaxSessionDuration (1-12 hours, default 1) caps
--duration-seconds. - Role chaining - assuming a role with role credentials - is capped at 1 hour, whatever the role allows (the last command, from the
try-opssession). - ExternalId: when a third party (a monitoring vendor) assumes a role in your account, the trust policy requires
"Condition": {"StringEquals": {"sts:ExternalId": "<a value they give you>"}}. It stops the confused deputy - the vendor being tricked into using your role on someone else's behalf. - MFA:
"Bool": {"aws:MultiFactorAuthPresent": "true"}in a trust policy forces--serial-numberand--token-code(a profile can carrymfa_serial). - Session policies:
--policy/--policy-arnson assume-role narrow the session below the role's permissions - the denial reads "because no session policy allows".
Roles for machines: no keys at all
- EC2: an instance profile carries a role to the instance; code on it gets credentials from the instance metadata service, rotated automatically. (AWS II, with IMDSv2.)
- EKS: IRSA and EKS Pod Identity give each Kubernetes service account its own role. (AWS II.)
- CI (GitHub Actions, GitLab): the CI system issues an OIDC token per job; IAM trusts the CI's OIDC provider, and the job calls
sts:AssumeRoleWithWebIdentity. No secret is stored anywhere.
The trust policy is where it is safe or not: it must check the token's audience and subject (which repository, which branch):
$ cat try/github-trust.json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:oncall-lab/shop:ref:refs/heads/main"
}
}
}
]
}
$ aws iam create-open-id-connect-provider --url https://token.actions.githubusercontent.com --client-id-list sts.amazonaws.com --query OpenIDConnectProviderArn --output text
arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com
$ aws iam create-role --role-name try-ci-shop --assume-role-policy-document file://try/github-trust.json --query Role.Arn --output text
arn:aws:iam::111122223333:role/try-ci-shop
$ aws sts assume-role-with-web-identity --role-arn arn:aws:iam::111122223333:role/try-ci-shop --role-session-name gha-main --web-identity-token file://try/token-main.jwt --query '[AssumedRoleUser.Arn,SubjectFromWebIdentityToken]' --output text
arn:aws:sts::111122223333:assumed-role/try-ci-shop/gha-main repo:oncall-lab/shop:ref:refs/heads/main
$ aws sts assume-role-with-web-identity --role-arn arn:aws:iam::111122223333:role/try-ci-shop --role-session-name gha-pr --web-identity-token file://try/token-pr.jwt
aws: [ERROR]: An error occurred (AccessDenied) when calling the AssumeRoleWithWebIdentity operation: Not authorized to perform sts:AssumeRoleWithWebIdentity
The job on main got in; the pull-request job did not, because its token's subject is repo:oncall-lab/shop:pull_request. A trust condition of repo:oncall-lab/* - or none at all - would hand the deploy role to any branch, any fork's PR workflow, or (without the repository in it) any GitHub repository in the world. Note that this call needs no AWS credentials: the token is the proof.
iam:PassRole
Giving a role to a service ("this Lambda / instance / ECS task runs as role X") requires iam:PassRole on that role. It is the permission that stops a developer who may launch instances from launching one with the admin role and reading its credentials. Scope it to the roles meant for that service, never Resource: "*".
In an interview: "What is a trust policy, and how do you give a CI pipeline AWS access without long-lived keys?" - "A trust policy is the resource policy on a role that says who may assume it. For CI you trust the CI system's OIDC provider, restrict the token's audience and subject to the repository and branch, and the job exchanges its OIDC token for temporary role credentials with AssumeRoleWithWebIdentity. No secret is stored, and every session expires."
You can now: create a role with a trust policy and permissions, assume it by hand and with a role profile, read the session ARN, explain when trusting :root needs an identity policy too, hit (and explain) the session duration and role chaining limits, and write a trust policy for GitHub Actions that only main can use.