IAM: users, roles & policies
Least privilege, roles over keys, evaluation.
Your AWS (Amazon Web Services) account has exactly one door, and the guard on it never takes a break. A person clicking around the console, a script running aws s3 cp, an EC2 (Elastic Compute Cloud) virtual machine fetching a file, one AWS service calling another: every one of those arrives at the same door as a signed API (application programming interface) request, and the guard decides whether the lock opens. That guard is IAM (Identity and Access Management). There is no side entrance and no open window. Nothing happens in your account that IAM did not agree to, which is why it is the security control plane for the whole platform, and why the Solutions Architect exam wants you to reason about it precisely rather than roughly.
Four words carry the entire model. A principal is whoever is knocking: an IAM *user* (a named person holding long-term credentials), a *role* (an identity a trusted party can borrow for a while), or an AWS service acting on your behalf. An action is the verb being attempted, like s3:GetObject. A resource is the specific thing that verb points at, named by its ARN (Amazon Resource Name), the unique address AWS gives everything it manages. A policy is the JSON (JavaScript Object Notation, a plain-text format for structured data) document that ties the three together. Two rules decide every outcome, and you should be able to recite both. Everything is *denied by default* until some policy explicitly allows it. And an *explicit deny always wins*: no allow, anywhere, in any policy, can overrule it.
What a policy actually says
A policy statement reads like a short sentence with four slots: an Effect (Allow or Deny), an Action list, a Resource list, and optional Condition keys that add a qualifier, such as aws:SourceIp (only from these addresses) or aws:MultiFactorAuthPresent (only if they proved a second factor). Attach a policy to an identity and it is *identity-based*. Attach it to a thing instead, an S3 (Simple Storage Service) bucket or an SQS (Simple Queue Service) queue, and it is *resource-based*, which means it has to name the principal out loud. Identity policies come in three shapes. AWS-managed ones are written and updated by AWS: handy, and usually wider than you actually want. Customer-managed ones are yours, versioned five deep so you can roll back a bad edit. Inline ones are welded into a single identity and invisible to anyone hunting for reusable permissions, so skip them. The skill you are building is least privilege: exactly these actions, on exactly these resources, and nothing beyond that. For humans there is one more habit worth forming. Do not attach policies to people. Put people in groups, attach the policy to the group, and the permission set lives in one place instead of twelve. Here is the whole chain from the CLI (command line interface):
# 1. Author a least-privilege policy: read ONE bucket, nothing morecat > reports-readonly.json <<'EOF'{"Version": "2012-10-17","Statement": [{"Sid": "ReadReportsBucket","Effect": "Allow","Action": ["s3:GetObject", "s3:ListBucket"],"Resource": ["arn:aws:s3:::reports-prod","arn:aws:s3:::reports-prod/*"]}]}EOF# 2. Register it as a customer-managed policyaws iam create-policy --policy-name reports-readonly \--policy-document file://reports-readonly.json# {# "Policy": {# "PolicyName": "reports-readonly",# "PolicyId": "ANPAJ2UCCR6DPCEXAMPLE",# "Arn": "arn:aws:iam::123456789012:policy/reports-readonly",# "DefaultVersionId": "v1",# "AttachmentCount": 0,# "CreateDate": "2026-07-13T09:14:22+00:00"# }# }# 3. Attach once to a group; humans inherit by membershipaws iam create-group --group-name analystsaws iam attach-group-policy --group-name analysts \--policy-arn arn:aws:iam::123456789012:policy/reports-readonlyaws iam create-user --user-name danaaws iam add-user-to-group --user-name dana --group-name analysts
Look at what dana can do now: list one bucket, read from it. That is the complete list. She cannot delete an object, open a different bucket, or touch any other service. Nothing denies her those things. Nothing grants them either, and in IAM that amounts to the same answer. Default deny is doing the work.
Check it before you trust it
Policies fail quietly. A mistyped ARN or a forgotten s3:ListBucket does not complain when you attach it. It shows up later as AccessDenied at two in the morning, on somebody's pager. The policy simulator is your dry run. You hand it a principal and the calls you are curious about, and it answers using the same evaluation engine AWS runs against live requests, except nothing actually happens:
aws iam simulate-principal-policy \--policy-source-arn arn:aws:iam::123456789012:user/dana \--action-names s3:GetObject s3:DeleteObject \--resource-arns arn:aws:s3:::reports-prod/q2/summary.pdf# {# "EvaluationResults": [# {# "EvalActionName": "s3:GetObject",# "EvalDecision": "allowed",# "MatchedStatements": [# { "SourcePolicyId": "reports-readonly",# "SourcePolicyType": "IAM Policy" }# ]# },# {# "EvalActionName": "s3:DeleteObject",# "EvalDecision": "implicitDeny",# "MatchedStatements": []# }# ]# }
allowed comes with the name of the statement that matched, including statements Dana only has because of her group membership. implicitDeny means nothing matched at all, which is the default verdict. There is a third answer, explicitDeny, meaning a Deny statement fired, and you would see it even with ten Allows sitting alongside. Simulate before you attach anything to an identity that touches production. Then pair that habit with IAM Access Analyzer, which keeps watching and tells you which of your resources are reachable from outside your account.
Roles: credentials that expire on purpose
A role is a costume, not a person. It carries permissions but holds *no long-term credentials* of its own, and nobody logs in as one. A principal *assumes* the role and gets back temporary keys from STS (Security Token Service), good for anywhere between 15 minutes and 12 hours, one hour if you do not ask for anything else. Every role comes with two separate documents, and the exam leans on that split constantly. The trust policy answers one question: *who may put this costume on*. The permissions policy answers a different one: *what the wearer may do*. That pairing is how a workload gets access with no stored secret anywhere. Attach a role to an EC2 instance through an *instance profile* (the console creates one quietly for you; from the CLI you run aws iam create-instance-profile yourself), to a Lambda function as its *execution role* (Lambda is AWS's run-your-code-without-servers service), or to an ECS (Elastic Container Service) task, and the platform does the assuming for you. EC2 delivers the auto-rotated credentials through the instance metadata service, Lambda drops them into environment variables, and ECS serves them from its container credentials endpoint.
# Trust policy: WHO may wear this role (EC2, in this case)cat > trust-ec2.json <<'EOF'{"Version": "2012-10-17","Statement": [{"Effect": "Allow","Principal": { "Service": "ec2.amazonaws.com" },"Action": "sts:AssumeRole"}]}EOFaws iam create-role --role-name reports-reader \--assume-role-policy-document file://trust-ec2.jsonaws iam attach-role-policy --role-name reports-reader \--policy-arn arn:aws:iam::123456789012:policy/reports-readonly# Only principals the trust policy names may assume the role.# (This one trusts only EC2 — to run assume-role yourself, add your# own user ARN as a second Principal in the trust policy first.)aws sts assume-role \--role-arn arn:aws:iam::123456789012:role/reports-reader \--role-session-name audit-check# {# "Credentials": {# "AccessKeyId": "ASIAIOSFODNN7EXAMPLE",# "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",# "SessionToken": "IQoJb3JpZ2luX2VjE...(hundreds of chars)",# "Expiration": "2026-07-13T10:14:22+00:00"# },# "AssumedRoleUser": {# "AssumedRoleId": "AROADBQP57FF2AEXAMPLE:audit-check",# "Arn": "arn:aws:sts::123456789012:assumed-role/reports-reader/audit-check"# }# }# On an EC2 instance with this role attached — zero keys configured:aws sts get-caller-identity# {# "UserId": "AROADBQP57FF2AEXAMPLE:i-0abcd1234efgh5678",# "Account": "123456789012",# "Arn": "arn:aws:sts::123456789012:assumed-role/reports-reader/i-0abcd1234efgh5678"# }
Two tells that a credential is temporary: the access key ID starts with ASIA (long-term user keys start with AKIA), and the response carries an Expiration. Roles also do the heavy lifting for cross-account access. A role in account B trusts principals from account A, so the two accounts share a doorway and never share a user. Roles handle federation too. With IAM Identity Center (the current name for what used to be AWS SSO, single sign-on), your staff authenticate against an outside identity provider, or against Identity Center's own built-in directory, and land in AWS holding a short-lived role session. Set up that way, the humans in your company need no IAM users at all.
The order of the checks, and where the exam trips you
When several kinds of policy apply to one request, picture a row of gates that all have to open. An explicit deny anywhere at all ends the request on the spot. Next come Service Control Policies (SCPs), the organization-wide caps you will wire up in the next lesson. An SCP never grants anything. It only limits, and it binds every identity inside a member account, root user included. The management account at the top is exempt. Their newer sibling, the resource control policy, puts the same kind of cap on resources. After that come resource-based policies, then identity-based policies, then permissions boundaries (a ceiling bolted onto one identity, handy when you want a team to create users without letting them create users more powerful than themselves), and finally session policies, handed in at the moment a role is assumed. Inside a single account, identity-based and resource-based policies form a *union*: either one can allow the call. Across two accounts, *both* sides have to allow it. Two more facts get tested often. IAM is a global service, so users, roles, and policies have no region, and changes spread with eventual consistency, meaning a role you created two seconds ago can briefly fail to be found. And the root user sits outside IAM's reach: no IAM policy can restrain it, only an SCP from a parent organization can, so you give it MFA (multi-factor authentication), strip its access keys, and then almost never touch it.
Audit what already exists before you tighten anything. The credential report gives you one CSV (comma-separated values, a plain spreadsheet-style text file) line per user, root included. Roles never appear in it, because they hold no long-term credentials to report on:
# Find every long-lived key in the account and how stale it isaws iam generate-credential-report# { "State": "STARTED" } <- first call kicks off generationaws iam generate-credential-report# { "State": "COMPLETE" } <- rerun until COMPLETE, then downloadaws iam get-credential-report --query Content --output text \| base64 -d | cut -d, -f1,4,9,10,11 | column -s, -t# user password_enabled access_key_1_active access_key_1_last_rotated access_key_1_last_used_date# <root_account> not_supported false N/A N/A# ci-legacy false true 2023-02-14T08:11:00+00:00 2026-07-12T23:58:00+00:00# dana true false N/A N/A
That ci-legacy row is the finding you will meet again and again: a key minted in 2023, still active, used yesterday. Anyone holding that string is that user. Move the workload onto a role, watch it work, then run aws iam delete-access-key.
AKIA key gets baked into an AMI (Amazon Machine Image, the template a virtual machine boots from), a container image, or a git commit nobody ever cleans up. Step two: an SSRF bug (server-side request forgery, where an attacker talks your application into making a request for them) persuades an instance to hand over the credentials from its metadata service. Cut both halves. Replace every stored key with a role, and require IMDSv2 (version 2 of the Instance Metadata Service) with aws ec2 modify-instance-metadata-options --instance-id i-0abcd1234efgh5678 --http-tokens required. IMDSv2 makes the caller fetch a session token with an HTTP PUT first, and that is a hoop most SSRF tricks cannot jump through.Least privilege inside one account only takes you so far. An account admin can unwind your careful policies in a click, and one account holding both dev and prod is a single blast radius wearing two hats. The stronger wall is the account boundary itself, and the Service Control Policies that cap whole accounts live in AWS Organizations. Next: how to lay accounts out, hang those guardrails on them, and keep the bill readable.
Long-lived access keys sitting on laptops are how most AWS breaches start. A role assumed through single sign-on or an instance profile hands out credentials with a clock ticking on them. A forgotten AKIA key in a public GitHub gist has no clock at all. When somebody asks for a key "just for the weekend," assume the weekend never ends and offer a role instead.
Policy evaluation is not a popularity contest where the friendliest statement wins. Explicit Deny beats everything. SCPs can shrink what even an administrator may do. Resource-based policies, bucket policies and KMS (Key Management Service) key policies among them, sit in the path as well. When an exam question looks like a clean Allow, it is usually hiding a Deny you skimmed past, or a missing PassRole (the permission to hand a role over to the service that will use it).
Treat IAM Access Analyzer findings and the unused-credentials report as a weekly chore, like putting the bins out. Permissions pile up the way clutter piles up, one small exception at a time, until every engineer can do everything and nobody remembers who asked for what.
Try this
Ask the simulator whether a role can read one specific S3 object, before you go attaching anything broader. Lab account only, with a role and a bucket you own.
aws iam simulate-principal-policy \--policy-source-arn arn:aws:iam::111122223333:role/AppReadRole \--action-names s3:GetObject \--resource-arns arn:aws:s3:::lab-app-data/orders/2026/07/file.json \--query 'EvaluationResults[].{Action:EvalActionName,Decision:EvalDecision}' --output tableaws sts get-caller-identity
------------------------------------------| SimulatePrincipalPolicy |+---------------+------------------------+| Action | Decision |+---------------+------------------------+| s3:GetObject | allowed |+---------------+------------------------+{"UserId": "AROAEXAMPLE:session","Account": "111122223333","Arn": "arn:aws:sts::111122223333:assumed-role/AppReadRole/session"}
Takeaway
Three habits to carry out of here: reach for a role before you reach for a key, run the simulator before you believe a policy, and when access behaves strangely, hunt for an explicit Deny or a missing PassRole first rather than last.
Next: pick one static key in your lab, replace it with an instance profile or a single sign-on role, and delete the old key the same day the role proves itself.