OnCallReady

Lesson 35.14 · AWS I: CLI, IAM, S3 & KMS · 18 min read

Roles and STS: trust policies, assume-role and temporary credentials

In plain words

A role is like a costume in a theatre's wardrobe. Nobody owns it; whoever is allowed can put it on for the length of a show, and while wearing it they have the powers written on its label. A sign on the wardrobe door (the trust policy) says who may take this costume. When the show ends the costume vanishes from them by itself.

So instead of giving every actor a permanent key to everything, you hand out time-limited costumes. Even a CI robot from another company can borrow one, if it shows an ID card the wardrobe trusts.

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:

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.

Roles for machines: no keys at all

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.

Why it helps

Temporary credentials are the single biggest security improvement you can make in AWS: a leaked role session dies within the hour, a leaked access key works until someone notices. Roles are how people switch accounts, how EC2 instances, containers and Lambda functions get permissions, and how modern CI deploys.

On call you will read trust policies and AssumeRole errors often: a pipeline that suddenly cannot deploy is very often a trust condition on the OIDC subject that no longer matches the branch, and the error message says so if you know where to look.

Commands in this lesson

cd cat aws eval unset tail ls

FAQ

What is the difference between a trust policy and a permissions policy?

The trust policy is the resource policy on the role itself: it says who may assume it (sts:AssumeRole, or AssumeRoleWithWebIdentity for OIDC). The permissions policies say what the role may do once assumed. You need both: being allowed in does not grant anything, and having permissions is useless if nobody may assume the role.

What does trusting the account root mean?

A Principal of arn:aws:iam::111122223333:root does not mean the root user. It means "principals of this account, if their own identity policies allow sts:AssumeRole on this role". It delegates the decision to IAM. Naming a user or role directly needs no extra permission on the caller side.

How long do role credentials last?

By default one hour. You can ask for up to the role's MaxSessionDuration (up to 12 hours) with --duration-seconds. Role chaining, assuming a role from a role session, is limited to one hour whatever the maximum says. When credentials expire you get ExpiredToken and simply assume again.

How does GitHub Actions get AWS access without a secret?

The job requests an OIDC token from GitHub, signed by token.actions.githubusercontent.com, with claims such as the repository and branch in sub and sts.amazonaws.com in aud. IAM has that issuer registered as an OIDC provider, and the role's trust policy checks aud and sub. The job calls AssumeRoleWithWebIdentity and gets temporary credentials.

What is iam:PassRole?

The permission to hand a role to an AWS service, for example to start an EC2 instance with an instance profile or create a Lambda function with a role. Without the check anyone could create a resource with an admin role and use it. Restrict PassRole to the specific roles a person or pipeline should be able to hand out.

In an interview Mid

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 which principals may assume it. For CI, register the CI system's OIDC provider in IAM and write a trust policy that allows sts:AssumeRoleWithWebIdentity only when the token's audience and subject match, for GitHub Actions aud = sts.amazonaws.com and sub = repo:org/repo:ref:refs/heads/main. The job exchanges its short-lived OIDC token for temporary role credentials (an ASIA key and a session token) that expire by themselves. No secret is stored anywhere, and the role's permissions policy limits what the pipeline can do.

Also asked: What is the difference between a role and a user? · What does sts:AssumeRole return? · What is role chaining, and what limit applies?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.