AWS IAM: Users, Roles, and Policies
The IAM mental model that makes AWS access control click: who can do what on which resource, why roles beat access keys, policy anatomy, and least-privilege habits.
IAM (Identity and Access Management) is the gate in front of everything in AWS, and it trips people up because they try to memorize it instead of understanding the one question it answers. Every API call to AWS - launching an EC2 instance, reading an S3 object, writing to a queue - gets checked against IAM before it runs. Get the mental model right and the pieces (users, roles, policies, conditions) stop being trivia and become obvious. This guide builds that model, walks the anatomy of a policy, explains why roles beat long-lived keys, and gives you the least-privilege habits that keep an account safe. If you have not read the AWS fundamentals guide yet, start there for the account and region model that IAM sits on top of.
The one question: who can do what, where, and when
Every IAM decision answers a single question about a single request:
Can this PRINCIPAL perform this ACTION on this RESOURCE, under these CONDITIONS?
- Principal - the who. A user, a role, or an AWS service acting on your behalf. It is whatever identity made the request.
- Action - the what. A specific API operation, always namespaced by service:
s3:GetObject,ec2:TerminateInstances,dynamodb:PutItem. - Resource - the which. The specific thing being acted on, named by ARN (Amazon Resource Name): a bucket, an object, a table, an instance.
- Condition - the when/where. Optional extra constraints: only from this IP range, only if the request is encrypted, only during a MFA-authenticated session.
Every policy you will ever write is just a structured way of expressing "allow (or deny) these actions on these resources for these principals under these conditions." Hold onto those four words - principal, action, resource, condition - and every screen in the IAM console becomes readable.
Users, groups, and roles
IAM gives you three identity constructs, and the difference between them is the single most important thing to get right.
- User - a permanent identity for a specific human or a legacy application. It has long-lived credentials: a console password and/or access keys (an access key ID plus secret access key). Those keys do not expire on their own. That permanence is exactly the problem, which is why users should be rare.
- Group - a bucket of users that share a set of policies. Groups are purely an organizational convenience: you attach policies to the group (
Developers,Admins) and every user in it inherits them. A group is not a principal - nothing "logs in as" a group, and a group cannot be the target of aPrincipalin a policy. - Role - an identity with permissions but NO permanent credentials. Nobody owns a role; principals assume it, and when they do, AWS hands back temporary credentials that expire (minutes to hours). A role has a trust policy (who is allowed to assume it) plus permission policies (what it can do once assumed).
The mental shift: a user is you, permanently; a role is a hat anyone trusted can put on, briefly. An EC2 instance assumes a role. A Lambda function assumes a role. A user in another account assumes a role. Your CI pipeline assumes a role. Because the credentials are temporary and issued on demand, there is nothing sitting on disk to leak.
Why roles beat long-lived access keys
This is the habit that separates a safe account from an incident. Prefer roles and temporary credentials over IAM user access keys, always.
Long-lived access keys are the number one cause of AWS breaches. They get committed to git, pasted into Slack, baked into container images, and left on old laptops - and because they never expire, a key leaked two years ago still works today. When you scope access to a role instead:
- Credentials are temporary. They expire automatically, so a leaked credential is useless within hours.
- Credentials are issued on demand by STS (Security Token Service), never stored at rest by you.
- Access is auditable. CloudTrail logs every
AssumeRole, so you can see who took which hat and when. - You avoid key rotation toil entirely, because there is no static key to rotate.
The practical rule: humans authenticate through IAM Identity Center (formerly AWS SSO) or federation and assume roles; workloads (EC2, Lambda, ECS, EKS pods) run as roles. The only place a raw access key is defensible is a narrow external integration that genuinely cannot assume a role, and even then you scope it tightly and rotate it on a schedule.
Identity-based vs resource-based policies
Policies come in two flavors, and knowing which is which explains a lot of "why can this thing access that thing" puzzles.
- Identity-based policies attach to a principal (a user, group, or role) and say "this identity may do X." Most policies are these.
- Resource-based policies attach to a resource (an S3 bucket, an SQS queue, a KMS key) and say "these principals may do X to me." They include a
Principalfield, because the resource is naming who it trusts. This is what lets an S3 bucket grant access to a role in another account without that account's admin touching the bucket's identity policies.
Access can be granted from either side. A role can reach a bucket because the role's identity policy allows s3:GetObject, OR because the bucket's resource policy names that role. When both exist, they add up (within an account). Cross-account access almost always needs both sides to agree.
Anatomy of a policy document
A policy is a JSON document with a list of statements. Once you can read the shape, you can read any policy in the console. Here is a real least-privilege example: a role for an app that reads and writes objects in one bucket prefix, and nothing else.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadWriteAppUploads",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Resource": "arn:aws:s3:::acme-app-uploads/user-data/*"
},
{
"Sid": "ListOnlyThatPrefix",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::acme-app-uploads",
"Condition": {
"StringLike": {
"s3:prefix": "user-data/*"
}
}
}
]
}
Walk the fields:
- Version - always
"2012-10-17". It is the policy language version, not a date you change. Leaving it off silently disables newer features. - Statement - a list of permission rules. Each is evaluated independently.
- Sid - an optional human label for the statement. Purely for your own readability.
- Effect -
AlloworDeny. Nothing happens without an explicitAllow;Denyis for carving exceptions out of a broader allow. - Action - the API operations, one or a list, each
service:Operation. Note the two different S3 actions above:GetObject/PutObjectact on objects, whileListBucketacts on the bucket itself - a common gotcha, because "list the objects" is a bucket-level permission, not an object-level one. - Resource - the ARN(s) this applies to. Object actions target
arn:aws:s3:::bucket/key/*; the list action targets the bucket ARN with no trailing path. Scoping the resource down to a single prefix is what makes this least-privilege instead of "all of S3." - Condition - optional guardrails. Here
s3:prefixrestricts listing to just theuser-data/prefix, so the role cannot enumerate the whole bucket.
That is the whole grammar. Every AWS policy, from a two-line snippet to a 300-line managed policy, is this same shape repeated.
How evaluation works: default deny, explicit deny wins
When a request comes in, IAM gathers every policy that applies (identity-based, resource-based, permission boundaries, service control policies) and runs a fixed decision:
- Start from an implicit deny. By default, nothing is allowed. If no policy says
Allow, the request is denied. This is why a fresh user or role can do literally nothing. - Look for an explicit Allow. If at least one applicable policy allows the action on the resource, the request is tentatively permitted.
- Look for an explicit Deny. If ANY applicable policy has a matching
Deny, the request is denied - full stop. An explicit deny always overrides any allow, no matter how many allows exist or where they come from.
So the rule to memorize: explicit deny beats explicit allow beats default (implicit) deny. This is what makes guardrails reliable. An org-wide Service Control Policy that denies s3:DeleteBucket cannot be undone by a well-meaning admin attaching an allow - the deny wins every time. It is also why "I added an Allow but it still fails" usually means a broader Deny (an SCP or a permission boundary) is trumping you. When a request is mysteriously blocked, look for a Deny before you look for a missing Allow.
Roles for workloads: instance profiles and IRSA
The payoff of roles is that your code never sees a credential. Two patterns you will use constantly:
EC2 instance profiles. You attach a role to an EC2 instance via an instance profile (a thin wrapper the console usually creates for you). The instance metadata service then serves short-lived credentials to anything running on that box, and the AWS SDKs pick them up automatically from the default credential chain. Your app calls S3 with zero keys in its config - the SDK fetches temporary creds behind the scenes and refreshes them before they expire. Use IMDSv2 (the token-based version) to protect that metadata endpoint from SSRF attacks.
IRSA / Pod Identity for Kubernetes. On EKS, individual pods assume roles through IRSA (IAM Roles for Service Accounts) or the newer EKS Pod Identity. You map a Kubernetes service account to an IAM role; pods using that service account get temporary AWS credentials scoped to exactly what that workload needs - not the broad permissions of the whole node. It is the same principle as an instance profile, pushed down to per-workload granularity. (See the Kubernetes fundamentals guide for how service accounts fit into the cluster.)
The rule that ties both together: never hardcode access keys in application code, environment files, or container images. If your app runs on AWS, it should get its credentials from a role, and the credential chain makes that invisible to your code. A grep of your repo for AKIA (the prefix of every access key ID) should return nothing.
The root account and MFA
Every AWS account has a root user - the email address you signed up with. It can do absolutely anything, including things no IAM policy can restrict (closing the account, changing billing, some account-wide settings). Because it is that powerful, treat it as break-glass:
- Enable MFA on root immediately, ideally a hardware key. A compromised root account is a compromised business.
- Do not use root for daily work. Create IAM users/roles (or better, IAM Identity Center) for everything operational. Lock the root credentials away.
- Do not create root access keys. There is almost no legitimate reason to, and they are a catastrophic thing to leak.
MFA is not just for root. Require it for every human identity, and use Condition blocks with aws:MultiFactorAuthPresent to gate sensitive actions behind an MFA-authenticated session.
Least-privilege habits that actually hold
Least privilege is easy to say and easy to abandon under deadline pressure. A few concrete habits keep it real:
- Start narrow, widen only when something breaks. Grant the minimum, run the workload, read the
AccessDeniederrors in CloudTrail, and add exactly the actions that were missing. This is far safer than starting with*and promising to tighten later (you never will). - Avoid wildcards in Action and Resource.
"Action": "s3:*"and"Resource": "*"are how accounts end up over-permissioned. Name the specific actions and scope the ARN to the specific bucket, prefix, or table. A wildcard is acceptable only when you genuinely mean all of it and have thought about it. - Use IAM Access Analyzer. It reads your CloudTrail activity and generates a least-privilege policy from what a role actually used, and it flags resources shared outside your account or org. Running it turns "I think this policy is tight" into evidence.
- Prefer roles over users, temporary over long-lived. Restated because it is the highest-leverage habit: humans assume roles via Identity Center, workloads run as roles, and static access keys are the rare exception you can justify.
- Use permission boundaries and SCPs as guardrails. Set a maximum ceiling (a permission boundary on a role, an SCP across the org) so that even a mistaken broad
Allowcannot exceed the intended limit. The explicit-deny-wins rule makes these ironclad. - Review and expire. Audit policies and access keys regularly, remove unused permissions, and delete keys and users that no one needs. Access that exists is access that can leak.
The shape of it
IAM is one question asked over and over: can this principal perform this action on this resource under these conditions? Users are permanent identities you should keep rare; groups just bundle policies onto users; roles are the preferred path because they hand out temporary, auto-expiring credentials that nothing has to store. Policies come as identity-based (attached to who) or resource-based (attached to what), and every one of them is the same JSON shape: Effect, Action, Resource, Condition. Evaluation is default-deny, and an explicit Deny always wins. Give workloads credentials through instance profiles and IRSA so keys never touch your code, guard the root account with MFA, and hold least privilege with narrow grants, no wildcards, and Access Analyzer. Get the four-word question into your head and IAM stops being a maze and becomes a system you can reason about.