IAM mental model
Principals, policies, boundaries, and evaluation order.
Strip away the web console and the dashboards, and every interaction with AWS is the same tiny event: someone shows up holding credentials and asks to run one API call (application programming interface, the machine-to-machine way software asks AWS to do a thing) against one resource. s3:GetObject on this bucket. ec2:TerminateInstances on that instance. One caller, one verb, one target.
The mental model that keeps you honest is an office building where every door is locked by default and stays that way. Your badge is a principal (an identity AWS can authenticate, like an IAM user or a role). The rules that decide which doors the badge opens are policies (JSON documents, JavaScript Object Notation, a plain-text way to write down structured data, that list which actions are allowed on which resources). One guard stands at every door, and the guard has amnesia: it reads the whole rulebook from scratch on every swipe, remembers nothing about the last one, and never props a door open. That guard is IAM (Identity and Access Management), and every AWS service calls it on every request.
The four things IAM is built from
Almost everything in IAM is one of four objects. Users are permanent identities that can carry long-lived access keys, the credential pairs whose ID starts with AKIA. Roles are identities with no permanent credentials at all: a person, an EC2 instance (Elastic Compute Cloud, a rented virtual machine), a Lambda function, or a CI job (continuous integration, the automation that builds and ships your code) temporarily assumes a role and is handed short-lived credentials by STS (Security Token Service, a vending machine that prints badges stamped with an expiry, usually an hour and twelve hours at the very outside, then dead). Policies are the rulebooks. The root user is the building's owner: it created the account, no IAM identity policy can restrain it, and it should hold zero access keys and sit behind hardware MFA (multi-factor authentication, a second physical factor beyond a password).
Policies attach in two places, and the difference decides a lot. An identity-based policy is stapled to a user or role and lists what that identity may do. A resource-based policy is stapled to the thing being touched, an S3 (Simple Storage Service, AWS's object storage) bucket policy or a KMS key policy (Key Management Service, where AWS stores encryption keys), and lists who may touch it. One is the set of doors your badge opens. The other is the set of badges a particular door will accept.
The prefix on a credential tells you how bad a leak is. A long-lived AKIA key keeps working until a human notices and revokes it, and leaked long-lived keys are still one of the most common ways attackers get their first foothold in real cloud breaches. A role session hands out temporary credentials whose key starts with ASIA; they expire on their own, often inside an hour, and they were scoped to a single job. The whole modern AWS posture collapses to one move: stop distributing standing AKIA keys, hand out short-lived ASIA sessions instead. Roles and STS are where that move happens.
How one request gets decided
When a signed request lands, AWS authenticates the principal, then gathers every policy that could apply: the principal's identity policies, the target resource's policy, and the guardrail layers you will meet in later lessons. There is the SCP (service control policy, an account-wide ceiling set by AWS Organizations that can only take permissions away, never grant them), and its resource-side twin the RCP (resource control policy, the same kind of org-wide ceiling, bolted onto resources like buckets and keys instead of onto identities). There is the permission boundary on an identity (a cap on what that identity's own policies are allowed to grant), and the session policy handed in at assume time (an extra squeeze applied to one session). Then it runs a fixed procedure. None of it is a vote, and nothing gets averaged.
Rule one: if any policy anywhere holds an explicit Deny that matches the request, the request dies on the spot and nothing else is read. Rule two: with no explicit deny in play, the request has to clear every guardrail that is present (each one must allow it) and at least one identity or resource policy must hold a matching Allow. Rule three: no matching allow anywhere means implicit deny, the door that was locked by default.
Two things fall out of this that trip people up. First, Allow statements form a union with no order. You cannot override a broad allow with a later, narrower allow, because there is no 'later'; the only tool that cuts a hole in an allow is an explicit Deny. Second, crossing an account boundary takes yes from both sides. Inside one account, a single allow (identity or resource) is enough. For a caller in account A reaching a resource in account B, the caller's identity policy must allow the action and the target's resource policy must allow the caller. Miss either one and the answer is deny.
Build a role, assume it, prove it
A role carries two separate policy slots, and confusing them is a classic first-day mistake. The trust policy is a resource-based policy on the role that answers exactly one question: who may assume me. The permissions policy answers a different question: what may a session of me do once it exists. Create both.
cat > trust.json <<'EOF'{"Version": "2012-10-17","Statement": [{"Effect": "Allow","Principal": { "AWS": "arn:aws:iam::111122223333:root" },"Action": "sts:AssumeRole"}]}EOFaws iam create-role \--role-name deploy-ec2-reader \--assume-role-policy-document file://trust.json
{"Role": {"Path": "/","RoleName": "deploy-ec2-reader","RoleId": "AROA4EXAMPLE5EXAMPLE","Arn": "arn:aws:iam::111122223333:role/deploy-ec2-reader","CreateDate": "2026-07-13T08:41:57+00:00","AssumeRolePolicyDocument": {"Version": "2012-10-17","Statement": [{"Effect": "Allow","Principal": { "AWS": "arn:aws:iam::111122223333:root" },"Action": "sts:AssumeRole"}]}}}
cat > perms.json <<'EOF'{"Version": "2012-10-17","Statement": [{ "Effect": "Allow", "Action": "ec2:DescribeInstances", "Resource": "*" },{ "Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::prod-artifacts/*" }]}EOFaws iam put-role-policy \--role-name deploy-ec2-reader \--policy-name read-only-scope \--policy-document file://perms.json# (returns no output on success)
Two details in there carry weight. The trust policy names the account ARN (Amazon Resource Name, the unique identifier string AWS stamps on every resource) arn:aws:iam::111122223333:root, which does not mean the root user. It delegates the assume decision back to identity policies inside account 111122223333: anyone there whose own policy allows sts:AssumeRole on this role can use it. And ec2:DescribeInstances gets Resource: "*" because the EC2 Describe-class actions genuinely do not support resource-level permissions. That is a documented IAM limitation, not sloppy scoping.
aws sts assume-role \--role-arn arn:aws:iam::111122223333:role/deploy-ec2-reader \--role-session-name smoke-test \--duration-seconds 900
{"Credentials": {"AccessKeyId": "ASIA4EXAMPLE7EXAMPLE","SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY","SessionToken": "IQoJb3JpZ2luX2VjEBoaCXVzLWVhc3QtMSJH...","Expiration": "2026-07-13T09:00:12+00:00"},"AssumedRoleUser": {"AssumedRoleId": "AROA4EXAMPLE5EXAMPLE:smoke-test","Arn": "arn:aws:sts::111122223333:assumed-role/deploy-ec2-reader/smoke-test"}}
Export those three values as AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN, then ask AWS who it now believes you are.
export AWS_ACCESS_KEY_ID=ASIA4EXAMPLE7EXAMPLEexport AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEYexport AWS_SESSION_TOKEN=IQoJb3JpZ2luX2VjEBoaCXVzLWVhc3QtMSJH...aws sts get-caller-identity
{"UserId": "AROA4EXAMPLE5EXAMPLE:smoke-test","Account": "111122223333","Arn": "arn:aws:sts::111122223333:assumed-role/deploy-ec2-reader/smoke-test"}
The ASIA prefix confirms the credentials are temporary. The session name you picked, smoke-test, is stamped into the ARN and written into CloudTrail (AWS's running log of who called which API, from which identity, in your account), so a later investigation can point at exactly which CI job or which human drove the role. Choose session names that name the caller, never session1.
Test the decision without firing the action
You do not have to run a real TerminateInstances to find out whether you could. Ask the evaluator straight, with the policy simulator.
aws iam simulate-principal-policy \--policy-source-arn arn:aws:iam::111122223333:role/deploy-ec2-reader \--action-names ec2:DescribeInstances s3:PutObject
{"EvaluationResults": [{"EvalActionName": "ec2:DescribeInstances","EvalResourceName": "*","EvalDecision": "allowed","MatchedStatements": [{ "SourcePolicyId": "read-only-scope", "SourcePolicyType": "role" }],"MissingContextValues": []},{"EvalActionName": "s3:PutObject","EvalResourceName": "*","EvalDecision": "implicitDeny","MatchedStatements": [],"MissingContextValues": []}]}
allowed means an identity policy grants it. implicitDeny means nothing did. This is the cheapest possible way to check a change before it ships, and it costs nothing because no real action runs.
simulate-principal-policy evaluates the identity's own policies, plus any resource or boundary policy you pass it by hand. It does not evaluate Organizations SCPs (or their resource-side RCP cousins) at all. So it can print allowed for an action that a service control policy will still deny in production. When a call fails with AccessDenied while the simulator swears it is fine, suspect an SCP before you suspect a bug.iam:PassRole, the quiet escalator
Handing a role to a workload is a permission of its own, and it is the one that quietly turns a modest deploy job into full account control. When you launch compute (ec2:RunInstances, lambda:CreateFunction, ecs:RunTask) you can attach an existing role to the new thing. Attaching it is gated by iam:PassRole. The difference it guards is the difference between opening a door yourself and being allowed to hand your badge to a stranger and tell them to walk in wearing it.
iam:PassRole with Resource: "*" and the holder can launch an EC2 instance carrying the account's admin role, then read that role's credentials straight off the instance metadata endpoint (a local-only address, 169.254.169.254, that every instance can query for its own keys). That is total takeover from a permission that looked like 'deploy'. Always scope PassRole to specific role ARNs, and add the iam:PassedToService condition so a role built for EC2 can never be passed to Lambda.Limits, scale, and the credential you still have to fix
Learn the hard edges before they cut. A customer-managed policy tops out at 6,144 characters, and a role accepts 10 attached managed policies by default. When your allowlists stop fitting, that is the scoping model telling you it is wrong, not a reason to ask for a quota increase. IAM is also eventually consistent: a role created a moment ago can fail sts:AssumeRole for a few seconds, which is why a Terraform run that creates a role and assumes it in the same pass needs retries. And the default ceiling of 1,000 roles per account arrives sooner than you would guess once every microservice, pipeline, and team wants one of its own.
Hygiene that survives an audit reads like this: no IAM users for humans (people sign in through your identity provider and federate into roles), workloads run as roles, and any AKIA key in aws iam list-access-keys output is a finding to chase, not furniture. Do not guess at least privilege, derive it: IAM Access Analyzer can read the last 90 days of CloudTrail and write a policy from what the role actually called. Reach for strong condition keys, too. aws:PrincipalOrgID on a resource policy shuts out every principal outside your organization in one line, and aws:SourceArn on a service trust policy blocks the confused-deputy trick, where a trusted AWS service is fooled into using its access for the wrong customer. That fix looks like this on a role an AWS service assumes:
{"Version": "2012-10-17","Statement": [{"Effect": "Allow","Principal": { "Service": "backup.amazonaws.com" },"Action": "sts:AssumeRole","Condition": {"StringEquals": { "aws:SourceAccount": "111122223333" },"ArnLike": {"aws:SourceArn": "arn:aws:backup:us-east-1:111122223333:backup-vault:*"}}}]}
The two Condition keys pin the trust down. aws:SourceAccount says the calling service must be acting for your account, and aws:SourceArn says it must be acting for one of your backup vaults specifically, so a stranger's vault in another account cannot trick AWS Backup into assuming your role. While you are tightening things, keep --duration-seconds as short as the job can stand; a session that lives fifteen minutes is a far smaller prize for an attacker than one that lives twelve hours.
Past a few hundred roles, stop writing one policy per team and switch to ABAC (attribute-based access control). Tag your principals and your resources, then write a single policy that compares aws:PrincipalTag against the resource's own tags. One policy scales to thousands of identities where a thousand hand-written ones turn into a maintenance swamp.
One credential problem is still sitting there untouched: the CI system itself. If your pipeline keeps an AKIA key in a secret variable so it can call sts:AssumeRole, you have not removed the long-lived secret, you have relocated it into your CI provider. The next lesson shuts that hole with OIDC (OpenID Connect, a standard way for one system to prove an identity to another with a signed token) federation, where GitHub Actions or GitLab CI trades a signed, short-lived job token directly for role credentials, so no AWS key sits anywhere in your pipeline config.
s3:* on a bucket, the other explicitly denies s3:DeleteObject on that same bucket. The role calls DeleteObject. What happens?s3:GetObject on a bucket in account B. Account B's bucket policy says nothing about account A. Can the role read the object?aws iam simulate-principal-policy reports allowed for an action, but in production the same call fails with AccessDenied. What is the most likely cause?