Learn
IAM roles, policies, users and instance profiles — the mental model
Why AWS needs four different objects to answer one question, what each is actually for, and how to tell which one you need. With the trust-policy rule that explains most access-denied errors.
Most AWS confusion is IAM confusion. Four objects — users, roles, policies, instance profiles — exist to answer what feels like one question: is this allowed? Learn what each is genuinely for and the whole thing collapses into something simple.
The one-sentence version
A policy is a document listing permissions. A user is a human identity. A role is an identity something temporarily becomes. An instance profile is the envelope that delivers a role to an EC2 instance.
Policies are attached to the other three. On their own, a policy does nothing at all.
Policies: the permissions themselves
A policy is JSON that answers three things: which actions, on which resources, allowed or denied.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::my-bucket/*"
}]
}
That grants reading and writing objects in my-bucket — and nothing else. Not listing the bucket (s3:ListBucket, which acts on the bucket ARN, not the object ARN), not other buckets, not deleting the bucket.
The distinction that catches everyone: arn:aws:s3:::my-bucket and arn:aws:s3:::my-bucket/* are different resources. The first is the bucket, the second is the objects inside it. Bucket-level actions like ListBucket need the first; object-level actions like GetObject need the second. A policy with only one of them produces the classic "I can list but not read" or "I can read but not list" confusion.
Two flavours:
| Type | Lives where | Best for |
|---|---|---|
| Managed | Standalone object, attachable to many identities | Shared permission sets reused across roles |
| Inline | Embedded inside one role or user | One-off grants that should die with their owner |
Inline policies have an underrated property: they cannot outlive the thing they are attached to. Delete the role and the policy goes with it. This is why Korve uses inline policies for connection wiring — undoing a connection leaves nothing orphaned in your account.
Users: humans and long-lived credentials
An IAM user is a permanent identity with permanent credentials — a password for the console, access keys for the API. That permanence is the problem. An access key that leaks is valid until someone notices and revokes it.
Modern practice is to minimise users: humans authenticate through identity federation or IAM Identity Center, and applications never get a user at all. If you find yourself putting access keys on a server, that is the signal you wanted a role.
Roles: identities things temporarily assume
A role is a set of permissions with no permanent credentials attached. Something assumes the role and receives temporary credentials that expire automatically — typically within hours.
Every role has two halves, and mixing them up is the single most common IAM error:
| Half | Question it answers | Called |
|---|---|---|
| Trust policy | Who is allowed to become this role? | The assume-role policy |
| Permissions policy | What can they do once they have? | The attached policies |
A role whose permissions are perfect but whose trust policy does not name the right principal is unusable. The error you get — access denied on sts:AssumeRole — points at the trust half, and people go and edit the permissions half, which is already fine.
A trust policy allowing EC2 instances to assume a role:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "ec2.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}
Note the principal is the service, not your account or a user. You are saying "the EC2 service may hand this role to instances." Change ec2.amazonaws.com to lambda.amazonaws.com and you have a Lambda execution role.
Instance profiles: the part nobody explains
Here is the piece that makes people give up. You cannot attach a role directly to an EC2 instance. Roles are attached to instances through a container called an instance profile.
For every other service — Lambda, ECS tasks, CodeBuild — you attach the role and you are done. EC2 alone requires this wrapper, for historical reasons that do not benefit you at all.
In practice the profile has the same name as its role and holds exactly one role, so it looks like pointless indirection, and it mostly is. But skip it and your instance has no permissions, with an error that mentions neither profiles nor the missing link.
The full chain for an instance to reach a bucket is therefore:
EC2 instance
└── instance profile
└── role
├── trust policy (ec2.amazonaws.com may assume this)
└── permissions policy (s3:GetObject on arn:aws:s3:::my-bucket/*)
Four objects and a specific assembly order, for the sentence "this server can read this bucket." When Korve wires ec2 → s3, this chain is what it builds.
How permissions are actually evaluated
When a request arrives, AWS evaluates every applicable policy:
- An explicit
Denyanywhere wins. Always, immediately, regardless of any Allow. - Otherwise, an explicit
Allowpermits the request. - Otherwise the request is denied by default.
That third rule matters: permissions are additive from a base of nothing. There is no inheritance to reason about, no ordering, no precedence beyond Deny-beats-Allow. If nothing granted it, it is denied.
So when you hit access denied, ask in order: is anything granting this at all? Is the resource ARN exactly right, including the /* question? Is there an explicit Deny — from a permissions boundary, an SCP, or a resource policy? Is the trust policy right, if a role is being assumed?
Least privilege, practically
"Grant only what is needed" is easy to say and tedious to do, which is why "Action": "" on "Resource": "" is so common. Three habits that make it tractable:
- Start from the specific ARN, not the wildcard. Write the resource first, then add actions until the thing works. Going the other way — starting broad and narrowing later — never actually gets narrowed.
- One role per workload, not one shared role. Shared roles accumulate the union of everything anyone needed.
- Let the connection define the grant. If a permission exists because service A talks to service B, it should be created and removed with that relationship. That is precisely what wiring a connection in Korve does — the policy is scoped to the connected resource, and removing the line revokes it.
The generated policy is a real IAM document you can read, audit and export. Nothing is hidden, and nothing is broader than the line you drew.
Build this on a canvas instead of the console.
Start Korve free →