Start free
← All guides

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.

Updated 2026-08-02

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:

TypeLives whereBest for
ManagedStandalone object, attachable to many identitiesShared permission sets reused across roles
InlineEmbedded inside one role or userOne-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:

HalfQuestion it answersCalled
Trust policyWho is allowed to become this role?The assume-role policy
Permissions policyWhat 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:

  1. An explicit Deny anywhere wins. Always, immediately, regardless of any Allow.
  2. Otherwise, an explicit Allow permits the request.
  3. 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:

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 →