Roles, trust policies, and PassRole
Who may assume whom — and the quiet escalator.
A role is a uniform hanging in a locker at the back of the building. Nobody owns it. It has no pockets full of permanent keys. Anyone on the approved list can lift it off the hook, wear it for a shift, and hang it back up. While you have it on, the doors that open for you are the uniform's doors, not yours. That is an IAM role (Identity and Access Management, the AWS service that decides who is allowed to do what): an identity with no permanent password and no permanent access key, borrowed for a while by whoever is allowed to borrow it.
Two separate documents govern that locker, and mixing them up causes most of the pain in this area. Taped to the locker door is the trust policy, the list of who may take the uniform down. Folded in the uniform's pocket is the permissions policy, the rules for what the wearer may do. AWS stores the two in different places, checks them at different moments, and says nothing at all when one of them turns out far looser than you meant it to be.
Two Documents, Two Different Questions
The trust policy is a resource-based policy, meaning it hangs off the thing being accessed (the role) rather than off the caller. You cannot forget to write it, because aws iam create-role refuses to run without --assume-role-policy-document. Inside it, the Principal block names who may call sts:AssumeRole (Security Token Service, the part of AWS that mints short-lived credentials), and the Condition block narrows that down to the circumstances you are willing to accept.
{"Version": "2012-10-17","Statement": [{"Sid": "PartnerAuditOnly","Effect": "Allow","Principal": {"AWS": "arn:aws:iam::444455556666:role/audit-runner"},"Action": "sts:AssumeRole","Condition": {"StringEquals": {"sts:ExternalId": "4f2c9a1e-audit-2026"}}}]}
Two details in that file do the real work. The Principal is one specific role ARN (Amazon Resource Name, the unique address of an AWS thing) inside the partner's account, not arn:aws:iam::444455556666:root. Most third-party integration guides tell you to paste the :root form. Despite how it reads, that form has nothing to do with the partner's root user. It hands the decision over to the partner's own IAM administrators, so whoever they choose to grant sts:AssumeRole walks in, including a contractor they hire next year. You never see that grant and nobody tells you when it changes. The sts:ExternalId condition is the second detail: a shared secret the partner has to send on the call. It exists for the confused deputy problem, where a partner who legitimately holds access into your account is talked into using that access on behalf of one of their other customers. The value runs from 2 to 1,224 characters of letters, digits and a short list of punctuation, so a fresh random identifier per customer is the right shape.
aws iam create-role \--role-name ci-deploy \--assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::111122223333:role/build-runner"},"Action":"sts:AssumeRole"}]}'
An error occurred (MalformedPolicyDocument) when calling the CreateRole operation: Invalid principal in policy: "AWS":"arn:aws:iam::111122223333:role/build-runner"
The role named build-runner does not exist yet. IAM checks that a named user or role is real at the moment you save the policy, so trust policies and the identities they name have to be created in the right order. Infrastructure-as-code tools walk into this constantly on a fresh account: two resources that point at each other, and whichever one lands first blows up.
The identical error turns up much later for a different reason, and that version is nastier. When you save a trust policy that names a specific role or user, IAM quietly swaps the ARN for that principal's internal unique ID, a string like AROA2EXAMPLEID4RQ7Q. The console swaps it back whenever it draws the policy on screen, so you normally never notice. Delete the principal and the swap has nothing to map back to, so the raw ID surfaces in the document and the trust stops working. Recreating the role under the exact same name does not repair it, because the new role gets a brand new unique ID. You have to open the trust policy and paste the ARN in again. AWS built it this way on purpose: without the swap, anyone able to delete and recreate a role would inherit every trust the old one held.
One thing gets repeated as fact in a lot of blog posts and is wrong. A trust policy can name a live session. "AWS": "arn:aws:sts::111122223333:assumed-role/admin/alice" is a documented, legal principal and AWS will accept it. It is still a poor choice, because the session name is picked by the caller, the session evaporates within hours, and you have pinned a long-lived document to something disposable. AWS recommends naming the durable role instead, arn:aws:iam::111122223333:role/admin, and tightening from there with conditions. That distinction bites in practice, because aws sts get-caller-identity hands you the sts form while the iam form is what belongs in the file.
aws iam create-role \--role-name partner-audit \--assume-role-policy-document file://trust-partner.json \--max-session-duration 3600 \--query 'Role.Arn' --output textaws iam attach-role-policy \--role-name partner-audit \--policy-arn arn:aws:iam::aws:policy/SecurityAuditaws iam get-role --role-name partner-audit \--query 'Role.{Arn:Arn,MaxSession:MaxSessionDuration,RoleId:RoleId}'
arn:aws:iam::111122223333:role/partner-audit{"Arn": "arn:aws:iam::111122223333:role/partner-audit","MaxSession": 3600,"RoleId": "AROA2EXAMPLEID4RQ7Q"}
Which Side Has To Say Yes
Across account lines, both sides have to agree. The caller's account needs an identity policy allowing sts:AssumeRole on the target role, and the target role's trust policy has to name the caller. Either side alone returns AccessDenied, which is why debugging a cross-account role always means opening two accounts and reading two documents.
Inside a single account the rule turns asymmetric, and that asymmetry trips up experienced people. An identity policy granting sts:AssumeRole is never sufficient on its own; the trust policy has to say yes. A trust policy naming the caller's ARN directly is sufficient on its own, with no identity policy needed anywhere. Then there is the middle case, "AWS": "arn:aws:iam::111122223333:root" pointing at your own account. Read that line as a handoff rather than as a grant. It means "decide this using my account's IAM policies", and once you write it, the caller does need sts:AssumeRole granted to it somewhere.
One rule changed on 21 September 2022 and still catches people who learned AWS before then. A role used to trust itself implicitly, so a long-running session could re-assume its own role to reset the clock. AWS removed that, and after a long grandfathering period the old behaviour is gone for everyone. A role that needs to assume itself must now list its own ARN in its own trust policy. If you inherited a batch job that renewed itself quietly for years and then started failing, this is almost always why.
aws sts assume-role \--role-arn arn:aws:iam::111122223333:role/partner-audit \--role-session-name alice-quarterly-audit \--external-id 4f2c9a1e-audit-2026 \--duration-seconds 3600
{"Credentials": {"AccessKeyId": "ASIAY34FZKBOKMUTVV7A","SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY","SessionToken": "IQoJb3JpZ2luX2VjEHUaCXVzLWVhc3QtMSJHMEUCIQ...abbreviated...","Expiration": "2026-07-27T10:12:44+00:00"},"AssumedRoleUser": {"AssumedRoleId": "AROA2EXAMPLEID4RQ7Q:alice-quarterly-audit","Arn": "arn:aws:sts::111122223333:assumed-role/partner-audit/alice-quarterly-audit"}}
Look closely at the identifiers in that response. The access key starts ASIA, not AKIA. ASIA means temporary: it stops working at the Expiration timestamp with no help from you, and it is useless without the SessionToken that shipped alongside it. The caller ARN becomes arn:aws:sts::111122223333:assumed-role/partner-audit/alice-quarterly-audit, where the last segment is the session name the caller chose. The caller chose it, so treat it as a label and never as evidence. When you want a name in your audit trail that the session cannot rewrite, pass --source-identity at assume time. Two things make it work: the trust policy has to allow the sts:SetSourceIdentity action for that principal, and once the value is set it is frozen for the life of the session. It also travels forward into any further roles that session goes on to assume, which is the property --role-session-name lacks.
Get the external ID wrong and STS tells the caller nothing useful, on purpose. There is no hint about which condition failed, because a helpful error here would be a free oracle for anyone probing your account.
aws sts assume-role \--role-arn arn:aws:iam::111122223333:role/partner-audit \--role-session-name probe \--external-id guessed-value
An error occurred (AccessDenied) when calling the AssumeRole operation: User: arn:aws:sts::444455556666:assumed-role/audit-runner/partner-session is not authorized to perform: sts:AssumeRole on resource: arn:aws:iam::111122223333:role/partner-audit
Sessions That Die On Their Own
MaxSessionDuration is a ceiling set per role, anywhere from one hour to twelve. Callers then request a length with --duration-seconds, from 900 seconds up to that ceiling, and get one hour if they ask for nothing. Shorter is better for the same reason a hotel keycard expires at checkout rather than at the end of the year. A credential someone scraped out of a build log is worth whatever time remains on it and nothing whatsoever after that.
One hard limit overrides all of it. If you assume a role and then use that session to assume a second role, which AWS calls role chaining, the second session is capped at one hour no matter what either role's MaxSessionDuration says. Asking for more is not quietly trimmed down. The call fails.
aws sts assume-role \--role-arn arn:aws:iam::111122223333:role/deploy-prod \--role-session-name pipeline-42 \--duration-seconds 7200
An error occurred (ValidationError) when calling the AssumeRole operation: The requested DurationSeconds exceeds the 1 hour session limit for roles assumed by role chaining.
EC2 instances (Elastic Compute Cloud, rented virtual machines) are the exception worth committing to memory. Credentials delivered to an instance through an instance profile are refreshed for you automatically, and MaxSessionDuration does not apply to sessions that AWS services create on your behalf. The instance keeps receiving fresh ASIA keys for as long as it runs, rotated well before each set expires. Handy for the application. It also means a foothold on that box is a renewing credential rather than an expiring one, so "the keys will time out" is not a containment plan.
When a session leaks there is no button that reaches out and kills it. Temporary credentials are validated by signature and expiry, not by a lookup in some session table AWS keeps on your behalf. What you do instead is attach a deny keyed to when the session was issued, which is exactly what the console's "Revoke active sessions" button writes for you.
aws iam put-role-policy \--role-name payments-api-prod \--policy-name AWSRevokeOlderSessions \--policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":["*"],"Resource":["*"],"Condition":{"DateLessThan":{"aws:TokenIssueTime":"2026-07-27T10:04:30Z"}}}]}'aws iam get-role-policy \--role-name payments-api-prod \--policy-name AWSRevokeOlderSessions \--query 'PolicyDocument.Statement[0].Condition'
{"DateLessThan": {"aws:TokenIssueTime": "2026-07-27T10:04:30Z"}}
PassRole, The Quiet Escalator
Assuming a role means wearing the uniform yourself. iam:PassRole is a different move: you hand the uniform to a machine, tell the machine which job to run, and the machine wears it. You never touch the credentials. The Lambda function holds them, or the EC2 instance does, or the Glue job does. Since you also wrote the code that machine runs, the gap between wearing the uniform and directing whoever wears it turns out to be mostly philosophical.
There is no API call behind iam:PassRole. You will never find a PassRole event in CloudTrail, because nothing ever calls it directly. It is a permission the receiving service checks against your identity at the moment you wire up the resource: ec2:RunInstances with an instance profile, lambda:CreateFunction with an execution role, cloudformation:CreateStack with a service role, and dozens more. To see which role went where, you read the log entry for the call that created the resource. Miss the permission and that parent call fails, with the role name printed in the message. One boundary worth knowing: you can only pass a role to a service in the same account, so cross-account setups need a role in the receiving account that assumes the one in yours.
aws lambda create-function \--function-name nightly-report \--runtime python3.13 \--role arn:aws:iam::111122223333:role/app-admin \--handler app.handler \--zip-file fileb://fn.zip
An error occurred (AccessDeniedException) when calling the CreateFunction operation: User: arn:aws:iam::111122223333:user/deploy-bot is not authorized to perform: iam:PassRole on resource: arn:aws:iam::111122223333:role/app-admin because no identity-based policy allows the iam:PassRole action
The fix is to scope the grant along both axes: which roles may be passed, and which service they may be passed to.
{"Version": "2012-10-17","Statement": [{"Sid": "PassOnlyAppRolesAndOnlyToLambda","Effect": "Allow","Action": "iam:PassRole","Resource": "arn:aws:iam::111122223333:role/app/*","Condition": {"StringEquals": {"iam:PassedToService": "lambda.amazonaws.com"}}}]}
Three honest caveats about that condition key. AWS states plainly that iam:PassedToService records only the final service that assumes the role, never the intermediate one that passed it along, so a pipeline tool handing a role onward to a compute service matches the compute service. The key is also not populated by every AWS service, and a policy requiring it denies the pass everywhere it is missing. Both of those produce a denial that looks exactly like a typo in your own policy, so test the specific call before you argue with anyone about which one you hit. Where a service does support the key, its companion iam:AssociatedResourceARN narrows things further by pinning the pass to particular target resources. One approach to avoid: tagging roles and filtering with ResourceTag to control who can pass what. AWS says outright that this does not give reliable results.
iam:PassRole on Resource: * can attach the account's most powerful role to a function it creates and run anything it likes inside. The sharper problem is timing. The check happens when the role is attached, not when the code runs. Someone holding only lambda:UpdateFunctionCode on an existing function can replace the code of a function that already carries an admin role, with no PassRole permission anywhere in their policies. The same shape applies to launch templates that already reference a fat instance profile. Audit which roles your compute is already carrying, not only who is allowed to hand them out.Instance profiles earn their own paranoia. A server-side request forgery bug (SSRF, where an attacker persuades your application to make an HTTP request on their behalf) that reaches the instance metadata service turns into live cloud credentials, because that service hands the instance's role credentials to anything asking from inside the box. IMDSv2 (Instance Metadata Service version 2, which forces a PUT request for a token before any read is allowed, and by default refuses to let the response leave the host) blocks the naive versions of that attack, so require it account-wide rather than hoping each team opts in. Then keep node roles thin, so what is on offer is boring. On Kubernetes and on ECS (Elastic Container Service, Amazon's own container scheduler), give each workload its own role instead of fattening the single role that every workload on the node can reach.
Prove It, Do Not Assume It
Reasoning about a policy in your head is how wildcards survive code review. Ask IAM directly instead.
aws iam simulate-principal-policy \--policy-source-arn arn:aws:iam::111122223333:user/deploy-bot \--action-names iam:PassRole \--resource-arns arn:aws:iam::111122223333:role/app-admin \--query 'EvaluationResults[].{Action:EvalActionName,Resource:EvalResourceName,Decision:EvalDecision}'
[{"Action": "iam:PassRole","Resource": "arn:aws:iam::111122223333:role/app-admin","Decision": "implicitDeny"}]
implicitDeny means nothing granted it, which is the answer you want for a deploy identity pointed at an admin role. allowed on that pair is a finding you write up. Know where the tool stops, though, because people trust it further than it deserves. It does evaluate service control policies (SCPs, the account-wide ceilings set by AWS Organizations), but it skips any SCP carrying a Condition, and most real SCPs carry conditions. It ignores resource control policies (RCPs, the newer ceiling applied to resources rather than identities) entirely. It does not simulate cross-account access for users or roles at all, so it will never tell you whether your partner can assume your role. Treat allowed as "the identity policies in this account permit it" and stop there.
For the cross-account question the simulator refuses to answer, the tool that pays for itself is IAM Access Analyzer. It reads the resource-based policies in your account, trust policies included, and reports the ones reachable by principals outside a boundary you define, which is usually your organization.
aws accessanalyzer list-findings \--analyzer-arn arn:aws:access-analyzer:eu-west-1:111122223333:analyzer/account-external \--filter '{"resourceType":{"eq":["AWS::IAM::Role"]},"status":{"eq":["ACTIVE"]}}' \--query 'findings[].{Role:resource,Who:principal,Since:createdAt}'
[{"Role": "arn:aws:iam::111122223333:role/legacy-partner-sync","Who": {"AWS": "444455556666"},"Since": "2023-04-11T08:31:02+00:00"}]
That single finding is the classic shape of a forgotten partner. The integration ended in 2023, the vendor's account is still written into the trust policy, and nobody has assumed the role since. Notice the Who block names a bare account number, which is the :root handoff again: whoever the vendor's administrators grant permission to, today or in three years. An attacker needs no vulnerability to use this. They need the old relationship still written down, and a way into an account that yours still trusts.
Be honest with yourself about what doing this properly costs. Tight trust policies and scoped PassRole grants mean a policy change for every new repository, every new service, every new partner. Teams that do not budget for that maintenance route around it, and the route is always identical: somebody widens a Resource to * during a 2am incident, the incident ends, and nobody ever narrows it back. If you cannot staff the reviews, choose a smaller number of roles with named owners over a large number of tight policies that quietly rot into wildcards.
Try This
Ten minutes in a lab account. Build a role that trusts only you, prove it can read and cannot write, and watch the denial name the exact action it refused. The first line converts the session ARN you are currently holding into the durable role ARN that belongs in a trust policy.
MY_ROLE=$(aws sts get-caller-identity --query Arn --output text \| sed 's|:sts:|:iam:|; s|assumed-role/\([^/]*\)/.*|role/\1|')aws iam create-role --role-name lab-readonly \--assume-role-policy-document "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"$MY_ROLE\"},\"Action\":\"sts:AssumeRole\"}]}" \--query 'Role.Arn' --output textCREDS=$(aws sts assume-role \--role-arn arn:aws:iam::111122223333:role/lab-readonly \--role-session-name lab \--query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' \--output text)export AWS_ACCESS_KEY_ID=$(echo "$CREDS" | cut -f1)export AWS_SECRET_ACCESS_KEY=$(echo "$CREDS" | cut -f2)export AWS_SESSION_TOKEN=$(echo "$CREDS" | cut -f3)aws sts get-caller-identity --query Arn --output textaws s3 ls | head -2aws iam create-user --user-name should-fail
arn:aws:iam::111122223333:role/lab-readonlyarn:aws:sts::111122223333:assumed-role/lab-readonly/lab2024-02-03 11:20:41 acme-artifacts2024-06-18 09:02:17 acme-logs-euAn error occurred (AccessDenied) when calling the CreateUser operation: User: arn:aws:sts::111122223333:assumed-role/lab-readonly/lab is not authorized to perform: iam:CreateUser on resource: arn:aws:iam::111122223333:user/should-fail because no identity-based policy allows the iam:CreateUser action
Two notes on running that. If the assume-role step returns AccessDenied on your first attempt, wait a few seconds and repeat it; a freshly created role takes a moment to propagate, and the retry succeeds. And notice the credentials come out through an explicit --query list rather than a bare dump of the Credentials block. With --output text, the command line tool prints the fields of a structure in alphabetical order of their key names, so a raw dump arrives as AccessKeyId, Expiration, SecretAccessKey, SessionToken. Reach for field two expecting your secret key and you get a timestamp instead. AWS recommends naming the fields you want for exactly this reason, and it costs one line.
That lab still leaned on a credential you were already holding. The next lesson removes the last standing key: your continuous integration system (CI, the automation that builds and ships your code) proves who it is with a short-lived token from OIDC (OpenID Connect, a standard way for one system to vouch for an identity to another), and the trust policy you learned to write here becomes the only thing standing between a build job and your account.
Takeaway
The trap worth remembering here: revoking sessions does not close the door. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.