AWS – IAM: Policies, Roles and Least Privilege

May 14, 20244 min readUpdated 8/24/2026

IAM is the service you get wrong first and the one that matters most. Every other AWS service asks IAM whether the caller is allowed to do this, so a misunderstanding here shows up as a confusing error somewhere else entirely.

The four things IAM has

ThingWhat it is
UserA person or a long-lived credential. Has a password and/or access keys
GroupA bag of users, so policies attach in one place. Groups cannot be nested and cannot be a principal
RoleA set of permissions something can assume temporarily. No password, no permanent keys
PolicyThe JSON document that actually grants or denies

The one to internalise is role. A role issues short-lived credentials that rotate automatically, and it is how a Lambda, an EC2 instance, a container or a CI pipeline should get access. If your code holds an access key, replace it with a role — that is the single highest -value security change most projects can make.

A policy, part by part

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadContentObjects",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::my-content-bucket/lovemesomecoding/*",
      "Condition": {
        "StringEquals": { "aws:PrincipalTag/team": "content" }
      }
    }
  ]
}
  • Version — always 2012-10-17. It is a policy language version, not a date you chose. The older 2008-10-17 silently disables policy variables
  • EffectAllow or Deny
  • Action — service-prefixed operations. Wildcards allowed: s3:Get*
  • Resource — ARNs. The trailing /* matters enormously; see below
  • Condition — where least privilege actually gets expressed

The bucket-versus-objects trap

These two ARNs are different resources and neither implies the other:

  • arn:aws:s3:::my-bucket — the bucket. s3:ListBucket acts on this
  • arn:aws:s3:::my-bucket/* — the objects. s3:GetObject acts on these

Grant GetObject on the bucket ARN without the /* and every read is denied, with a message that says access denied and not "you named the wrong resource". A policy needing both operations needs two statements.

Identity policies and resource policies

There are two places a permission can live, and an S3 bucket routinely needs both.

An identity policy attaches to a user, group or role: "this principal may do X." A resource policy attaches to the resource: "these principals may do X to me." S3 buckets, SQS queues, SNS topics, KMS keys and Lambda functions all take one.

Within one account, either is usually enough. Across accounts, both are required — the calling side must allow the call and the owning side must allow the caller. "I gave the role the permission and it still fails" is nearly always a missing resource policy.

Here is the real one serving this site. It says exactly one CloudFront distribution may read these objects, and nothing else can:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontOAC",
      "Effect": "Allow",
      "Principal": { "Service": "cloudfront.amazonaws.com" },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::lovemesomecoding.com/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/E30YUPLP37MY9U"
        }
      }
    }
  ]
}

Without that SourceArn condition, the policy would say "any CloudFront distribution in the world may read this bucket" — which is a real and commonly shipped mistake. The bucket itself stays entirely private with Block Public Access on.

How a request is actually decided

The order is short and worth memorising:

  1. An explicit Deny anywhere wins. Always, immediately, and it cannot be overridden by any allow
  2. Otherwise, an explicit Allow permits the request
  3. Otherwise it is denied — the default is no access

So permissions are additive across every attached policy, and a single Deny is absolute. That is what makes a "deny unless the request came over TLS" guardrail reliable, and it is also why adding a policy can never fix a request that some other policy denies.

Roles and the trust policy

A role has two policies and people mix them up constantly. The permission policy says what the role can do. The trust policy says who may assume it — and a role with no trust policy is unusable no matter how much access it grants.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "lambda.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }
  ]
}

That is the trust policy every Lambda execution role carries. For EC2 it is ec2.amazonaws.com, and the role reaches the instance through an instance profile.

Conditions are where least privilege lives

Most policies written in a hurry grant an action on "*" because scoping by ARN is fiddly. Conditions are usually the easier win, because they scope by something you already have.

Condition keyRestricts to
aws:ResourceTag/KeyResources carrying a tag — how a scheduled stop is confined to dev instances
aws:PrincipalTag/KeyCallers carrying a tag
aws:SourceArn / aws:SourceAccountThe specific service resource calling on your behalf
aws:SecureTransportHTTPS only
aws:MultiFactorAuthPresentSessions that used MFA

A tag-scoped Allow turns "this Lambda can stop any instance" into "this Lambda can stop instances someone deliberately labelled", and the difference is that a bug in the code can no longer reach production. Write the condition even when the code already filters — the code is what you are protecting against.

Practical rules

aws sts get-caller-identity
aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::111122223333:role/my-role \
  --action-names s3:GetObject \
  --resource-arns "arn:aws:s3:::my-bucket/file.txt"

get-caller-identity answers "who am I right now", which is the first question when something is denied unexpectedly. The policy simulator answers "would this be allowed", without having to try it.

Start from the managed policy that is close, then tighten. Turn on MFA for anything with a console password. And never use the account root user for daily work — it cannot be restricted by any policy, which is precisely why it should stay locked away.